diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index ef74cbdbf973cfbfe0a0c596ae4e97961d923f76..0557dd77520ee8597aa6213c09a952ee8e5499d4 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -22,6 +22,7 @@ "plugins": ["react", "@typescript-eslint"], "rules": { "semi": "off", + "react/jsx-props-no-spreading": "off", "@typescript-eslint/semi": ["error", "never"] } } diff --git a/frontend/public/index.html b/frontend/public/index.html index aa069f27cbd9d53394428171c3989fd03db73c76..2bc0e084ce0bca917aa25836d941d54011b64d6a 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -10,34 +10,11 @@ content="Web site created using create-react-app" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> - <!-- - manifest.json provides metadata used when your web app is installed on a - user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ - --> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> - <!-- - Notice the use of %PUBLIC_URL% in the tags above. - It will be replaced with the URL of the `public` folder during the build. - Only files inside the `public` folder can be referenced from the HTML. - - Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will - work correctly both with client-side routing and a non-root public URL. - Learn how to configure a non-root public URL by running `npm run build`. - --> - <title>React App</title> + <title>Guest Registration</title> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> - <!-- - This HTML file is a template. - If you open it directly in the browser, you will see an empty page. - - You can add webfonts, meta tags, or analytics to this file. - The build step will place the bundled scripts into the <body> tag. - - To begin the development, run `npm start` or `yarn start`. - To create a production bundle, use `npm run build` or `yarn build`. - --> </body> </html> diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 23b623dc27804e6390f5ce5422ae88d408e85172..4c2ab711ff2501d634315c3041da3394a142b2eb 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -8,11 +8,14 @@ }, "fnr": "National identity number", "header": { - "applicationTitleShort": "GREG", - "applicationTitleLong": "Guest Registration", + "applicationTitle": "Guest Registration", + "applicationDescription": "Registration service for guests", "selectLanguage": "Select language" }, "loading": "Loading...", "termsHeader": "Terms", - "staging": "Staging" + "staging": "Staging", + "firstName": "First name", + "lastName": "Last name", + "dateOfBirth": "Date of birth" } diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index d916c4911609a188677a2bbf3dffcdf27761dbc4..11a25642f5fb9b4214464761810703467700b845 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -7,12 +7,15 @@ "change": "Bytt språk til {{lang}}" }, "fnr": "Fødselsnummer", - "header":{ - "applicationTitleShort": "GREG", - "applicationTitleLong": "Gjesteregistrering", + "header": { + "applicationTitle": "Gjesteregistrering", + "applicationDescription": "Registreringstjeneste for gjester", "selectLanguage": "Velg språk" }, "loading": "Laster...", "termsHeader": "Vilkår", - "staging": "Staging" + "staging": "Staging", + "firstName": "Fornavn", + "lastName": "Etternavn", + "dateOfBirth": "Fødselsdato" } diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index db11f21eacd26a8402d7d1244315094909efbe62..21039b90560962d4338ef53f60348eb0d8edba48 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -8,12 +8,15 @@ "change": "Bytt språk til {{lang}}" }, "fnr": "National identity number", - "header":{ - "applicationTitleShort": "GREG", - "applicationTitleLong": "Gjesteregistrering", + "header": { + "applicationTitle": "Gjesteregistrering", + "applicationDescription": "Registreringsteneste for gjester", "selectLanguage": "Velg språk" }, "loading": "Lastar...", "termsHeader": "Vilkår", - "staging": "Staging" + "staging": "Staging", + "firstName": "Fornamn", + "lastName": "Etternamn", + "dateOfBirth": "Fødselsdato" } diff --git a/frontend/src/components/button/index.tsx b/frontend/src/components/button/index.tsx index 9eb200519a6b51d5342bd15a8a5b9a1b188e2fe7..7054c37015069fd236de9a1312f0979977135538 100644 --- a/frontend/src/components/button/index.tsx +++ b/frontend/src/components/button/index.tsx @@ -2,6 +2,7 @@ import styled from 'styled-components/macro' export const Button = styled.a` display: inline-block; + cursor: pointer; border-radius: 3px; padding: 0.5rem 0; margin: 0.5rem 1rem; diff --git a/frontend/src/components/dateinput/index.tsx b/frontend/src/components/dateinput/index.tsx index b7623b9cf0465ac7654eb3ff6ea2ab473034e716..a12c37a7dd0260d5190847a53849dc3e01923f40 100644 --- a/frontend/src/components/dateinput/index.tsx +++ b/frontend/src/components/dateinput/index.tsx @@ -1,29 +1,25 @@ -import React, { useState } from 'react' -import DatePicker, { registerLocale } from 'react-datepicker' +import React from 'react' +import DatePicker, { + ReactDatePickerProps, + registerLocale, +} from 'react-datepicker' + +import 'react-datepicker/dist/react-datepicker.css' +import { useTranslation } from 'react-i18next' + import nb from 'date-fns/locale/nb' import nn from 'date-fns/locale/nn' -import { addDays } from 'date-fns' -import { useTranslation } from 'react-i18next' -import 'react-datepicker/dist/react-datepicker.css' registerLocale('nb', nb) registerLocale('nn', nn) // CSS Modules, react-datepicker-cssmodules.css // import 'react-datepicker/dist/react-datepicker-cssmodules.css'; -const DateInput = () => { - const [startDate, setStartDate] = useState(new Date()) +const DateInput = (props: ReactDatePickerProps) => { const { i18n } = useTranslation() return ( - <DatePicker - dateFormat="yyyy-MM-dd" - locale={i18n.language} - selected={startDate} - minDate={startDate} - maxDate={addDays(new Date(), 365)} - onChange={(date: any) => setStartDate(date)} - /> + <DatePicker dateFormat="yyyy-MM-dd" locale={i18n.language} {...props} /> ) } diff --git a/frontend/src/components/debug/index.tsx b/frontend/src/components/debug/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..51f894357d626b1961d9f7feb1e75e053365cfdd --- /dev/null +++ b/frontend/src/components/debug/index.tsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { appTheme, appTimezone, appVersion, appInst } from 'appConfig' + +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 [isAuthenticated, setIsAuthenticated] = useState(false) + const [username, setUsername] = useState(undefined) + + if (!didContactApi) { + setDidContactApi(true) + fetch('http://localhost:3000/api/health/') + .then((res) => res.text()) + .then((result) => { + if (result === 'OK') { + setApiHealth('yes') + } else { + setApiHealth(result) + } + }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .catch((error) => { + setApiHealth('error') + }) + } + + 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) + if (data.isAuthenticated) { + setIsAuthenticated(true) + } else { + setIsAuthenticated(false) + getCSRF() + } + }) + .catch((err) => { + setIsAuthenticated(false) + 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) + setIsAuthenticated(false) + getCSRF() + }) + .catch((err) => { + console.log(err) + }) + } + + const d = [ + ['NODE_ENV', process.env.NODE_ENV], + ['Version', appVersion], + ['Timezone', appTimezone], + ['Language', i18n.language], + ['Theme', appTheme], + ['Institution', appInst], + ['API reachable?', apiHealth], + ['Csrf', csrf], + ['Authenticated?', isAuthenticated ? 'Authenticated' : 'Not Authenticated'], + ['username', username], + ] + return ( + <table> + <thead> + <strong>Debug</strong> + </thead> + <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> + <tbody> + {d.map(([key, value]) => ( + <tr> + <td>{key}</td> + <td>{value}</td> + </tr> + ))} + </tbody> + </table> + ) +} + +export default Debug diff --git a/frontend/src/components/form/fnr.tsx b/frontend/src/components/form/fnr.tsx index 16e356125bdef88dfbae3e90d18164ef31e6b318..ee7cd1f2a920bc3b443d11f4c5a084f0321b5065 100644 --- a/frontend/src/components/form/fnr.tsx +++ b/frontend/src/components/form/fnr.tsx @@ -1,11 +1,7 @@ import React from 'react' -import validator from '@navikt/fnrvalidator' -import { UseFormReturn } from 'react-hook-form' -export function isValidIdnr(data: string) { - const validationResult = validator.idnr(data) - return validationResult.status === 'valid' -} +import { UseFormReturn } from 'react-hook-form' +import { isValidFnr } from 'utils' interface FnrProps extends Partial<Pick<UseFormReturn, 'register'>> { name: string @@ -26,7 +22,7 @@ function Fnr(props: FnrProps) { // eslint-disable-next-line react/jsx-props-no-spreading {...register(name, { required: 'Fnr is required', - validate: isValidIdnr, + validate: isValidFnr, })} id="fnr" /> diff --git a/frontend/src/components/form/form.tsx b/frontend/src/components/form/form.tsx index 305eb2d302e1eb138480cff9399447c69d1c0e92..2964e7f2591152ec5e6fb2d5e83fe2ac40861538 100644 --- a/frontend/src/components/form/form.tsx +++ b/frontend/src/components/form/form.tsx @@ -22,6 +22,7 @@ export default function Form(props: FormProps) { ...{ // eslint-disable-next-line react/jsx-props-no-spreading ...child.props, + control: methods.control, register: methods.register, errors, key: child.props.name, diff --git a/frontend/src/components/form/input.tsx b/frontend/src/components/form/input.tsx index 231a731c4b4be4fd8e98e684373dbba1bbca134f..94e7332f465c98a0fa1561ffb3756ed4c37c8846 100644 --- a/frontend/src/components/form/input.tsx +++ b/frontend/src/components/form/input.tsx @@ -1,12 +1,25 @@ import React from 'react' +import styled from 'styled-components/macro' import { UseFormReturn } from 'react-hook-form' -interface InputProps extends Partial<Pick<UseFormReturn, 'register'>> { +interface InputProps + extends Partial<React.InputHTMLAttributes<HTMLInputElement>>, + Partial<UseFormReturn> { name: string errors?: any type?: 'text' | 'email' | 'number' } +export const StyledInput = styled.input` + width: 50%; + border: 1px solid; + border-width: 0.013rem; + border-radius: 0.3rem; + border-color: #ccc; + box-shadow: inset 0 0 0 0.013rem #4d4d4d; + padding: 0.625rem; +` + function Input(props: InputProps) { // eslint-disable-next-line react/jsx-props-no-spreading const { register, name, errors, type, ...rest } = props @@ -14,7 +27,7 @@ function Input(props: InputProps) { return <></> } // eslint-disable-next-line react/jsx-props-no-spreading - return <input {...register(name)} {...rest} /> + return <StyledInput {...register(name)} {...rest} /> } Input.defaultProps = { diff --git a/frontend/src/components/page/page.tsx b/frontend/src/components/page/page.tsx index f7b0c7adb6ed8f029d7b8a268134376cbf0ea197..2d95a68cc813c3a214ec92d67108016c3c431d13 100644 --- a/frontend/src/components/page/page.tsx +++ b/frontend/src/components/page/page.tsx @@ -1,10 +1,16 @@ import React from 'react' import { Helmet } from 'react-helmet' +import { useTranslation } from 'react-i18next' import styled from 'styled-components/macro' const StyledPage = styled.main` display: block; + justify-content: space-between; + margin: 0 auto; + max-width: ${(props) => props.theme.appMaxWidth}; + padding: ${(props) => + `0.5rem ${props.theme.horizontalPadding} 1rem ${props.theme.horizontalPadding}`}; ` const StyledPageHeader = styled.h2` @@ -17,15 +23,18 @@ const StyledPageHeader = styled.h2` interface IPage { children: React.ReactNode - header: string + header?: string } export default function Page(props: IPage) { const { header, children } = props + const { i18n, t } = useTranslation() + const appTitle = t('common:header:applicationTitle') return ( <> - <Helmet> + <Helmet titleTemplate={`%s - ${appTitle}`} defaultTitle={appTitle}> + <html lang={i18n.language} /> <title>{header}</title> </Helmet> <StyledPage> @@ -35,3 +44,7 @@ export default function Page(props: IPage) { </> ) } + +Page.defaultProps = { + header: null, +} diff --git a/frontend/src/globalStyles.ts b/frontend/src/globalStyles.ts index f81dda019fb53dbc61108181c833a0187aa06164..d158638476ed156b47e87db0e1e30432cd210ae0 100644 --- a/frontend/src/globalStyles.ts +++ b/frontend/src/globalStyles.ts @@ -1,6 +1,10 @@ import { createGlobalStyle } from 'styled-components/macro' const GlobalStyle = createGlobalStyle` + html { + font-size: clamp(24px, calc(24px * 1vw), 36px); + } + body { margin: 0; min-height: 100vh; diff --git a/frontend/src/routes/components/header.tsx b/frontend/src/routes/components/header.tsx index 0a4f62cbdfa3e2ff31d8a8d7a05155ed1abf5361..bb96b6b82cbf60461f09d60366b50d5187da2619 100644 --- a/frontend/src/routes/components/header.tsx +++ b/frontend/src/routes/components/header.tsx @@ -1,10 +1,10 @@ import React from 'react' +import { Link } from 'react-router-dom' import styled from 'styled-components/macro' import { useTranslation } from 'react-i18next' import LogoBar from '../../components/logobars/LogoBar' import LanguageSelector from '../../components/languageselector' - const MainWrapper = styled.div` color: ${({ theme }) => theme.page.headerColor}; background-color: ${({ theme }) => theme.page.headerBackgroundColor}; @@ -14,8 +14,9 @@ const Main = styled.div` display: flex; justify-content: space-between; margin: 0 auto; - max-width: ${props => props.theme.appMaxWidth}; - padding: ${props => `0.5rem ${props.theme.horizontalPadding} 1rem ${props.theme.horizontalPadding}`}; + max-width: ${(props) => props.theme.appMaxWidth}; + padding: ${(props) => + `0.5rem ${props.theme.horizontalPadding} 1rem ${props.theme.horizontalPadding}`}; ` const Menu = styled.ul` @@ -33,15 +34,15 @@ const TitleBox = styled.div` padding-left: 3rem; ` -const ShortTitle = styled.div` +const Title = styled.div` font-size: 2rem; ` -const LongTitle = styled.div` +const Description = styled.div` font-size: 1rem; ` -function Header() { +const Header = () => { const { t } = useTranslation('common') return ( @@ -50,12 +51,14 @@ function Header() { <MainWrapper> <Main> <TitleBox> - <ShortTitle>{t('header.applicationTitleShort')}</ShortTitle> - <LongTitle>{t('header.applicationTitleLong')}</LongTitle> + <Link to="/"> + <Title>{t('header.applicationTitle')}</Title> + </Link> + <Description>{t('header.applicationDescription')}</Description> </TitleBox> <Menu> <MenuItem> - <LanguageSelector/> + <LanguageSelector /> </MenuItem> </Menu> </Main> diff --git a/frontend/src/routes/frontpage/index.tsx b/frontend/src/routes/frontpage/index.tsx index 826afe4dd56d8a379c1b0a550e6e05310323ed2c..d48137473b76eaebcbd02ee9b2aaaa7432f71fab 100644 --- a/frontend/src/routes/frontpage/index.tsx +++ b/frontend/src/routes/frontpage/index.tsx @@ -1,51 +1,27 @@ -import React, { useState } from 'react' +import React from 'react' -import DateInput from 'components/dateinput' import Page from 'components/page' -import { appTimezone, appVersion, appTheme } from 'appConfig' -import { Fnr, Form, Select, Input } from 'components/form' -import { useTranslation } from 'react-i18next' +import { Debug } from 'components/debug' +import { Link } from 'react-router-dom' export default function FrontPage() { - const [apiHealth, setApiHealth] = useState('not yet') - const [didContactApi, setDidContactApi] = useState(false) - const { t } = useTranslation(['common', 'footer']) - - if (!didContactApi) { - setDidContactApi(true) - fetch('http://localhost:3000/api/health/') - .then((res) => res.text()) - .then((result) => { - if (result === 'OK') { - setApiHealth('yes') - } else { - setApiHealth(result) - } - }) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .catch((error) => { - setApiHealth('error') - }) - } - return ( - <Page header="Greg main page"> - <DateInput /> - <Form onSubmit={() => {}}> - <Input name="firstName" /> - <Input name="lastName" /> - <Select name="gender" options={['female', 'male', 'other']} /> - <Fnr name={t('common:fnr')} /> - <button type="submit">Submit</button> - </Form> - <br /> - Version {appVersion} - <br /> - Timezone {appTimezone} - <br /> - Theme {appTheme} - <br /> - API reachable? {apiHealth} + <Page> + <p> + <strong>Routes</strong> + <ul> + <li> + <Link to="/">Front page</Link> + </li> + <li> + <Link to="/sponsor/">Sponsor</Link> + </li> + <li> + <Link to="/register/">Registration</Link> + </li> + </ul> + </p> + <Debug /> </Page> ) } diff --git a/frontend/src/routes/register/index.tsx b/frontend/src/routes/register/index.tsx index 2e7736db6ab03af384e17a9c2d9ea3ec75b456b9..629080bdea3a7580c42a5e41c25f53a569aa1c94 100644 --- a/frontend/src/routes/register/index.tsx +++ b/frontend/src/routes/register/index.tsx @@ -1,11 +1,78 @@ import React from 'react' +import { useTranslation } from 'react-i18next' +import { StyledInput } from 'components/form/input' +import DateInput from 'components/dateinput' +import Button from 'components/button' import Page from 'components/page' +import { useForm, Controller, SubmitHandler } from 'react-hook-form' +import format from 'date-fns/format' +import { postJsonOpts } from 'utils' + +type RegisterFormData = { + first_name: string + last_name: string + date_of_birth: Date +} export default function Register() { + const { t } = useTranslation(['common']) + + const submit: SubmitHandler<RegisterFormData> = (data) => { + const payload = { + first_name: data.first_name, + last_name: data.last_name, + date_of_birth: format(data.date_of_birth, 'yyyy-MM-dd'), + } + console.log('submitting', JSON.stringify(payload)) + fetch('http://localhost:3000/api/ui/v1/register/', postJsonOpts(payload)) + .then((res) => res.text()) + .then((result) => { + console.log('result', result) + }) + .catch((error) => { + console.log('error', error) + }) + } + + const { + register, + control, + handleSubmit, + // setValue, + // formState: { errors }, + } = useForm<RegisterFormData>() + const onSubmit = handleSubmit(submit) + return ( - <Page header="Register a new guest"> - <p>Todo</p> + <Page header="Register as a guest"> + <form onSubmit={onSubmit}> + <StyledInput + {...register(`first_name`)} + placeholder={t('common:firstName')} + /> + <StyledInput + {...register(`last_name`)} + placeholder={t('common:lastName')} + /> + <Controller + name="date_of_birth" + control={control} + render={({ field }) => ( + <DateInput + customInput={<StyledInput />} + placeholderText={t('common:dateOfBirth')} + onChange={(date) => field.onChange(date)} + selected={field.value} + /> + )} + /> + {/* <Select name="gender" options={['female', 'male', 'other']} /> + <Fnr name={t('common:fnr')} /> */} + <Button as="button" type="submit"> + Submit + </Button> + </form> </Page> ) } diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..80e6cd182ccc39586653a9f3200ba64bcd862505 --- /dev/null +++ b/frontend/src/utils/index.ts @@ -0,0 +1,43 @@ +import validator from '@navikt/fnrvalidator' + +export function getCookie(name: string) { + if (!document.cookie) { + return null + } + + const cookies = document.cookie + .split(';') + .map((c) => c.trim()) + .filter((c) => c.startsWith(`${name}=`)) + + if (cookies.length === 0) { + return null + } + return decodeURIComponent(cookies[0].split('=')[1]) +} + +export function maybeCsrfToken() { + const csrfToken = getCookie('csrftoken') + if (!csrfToken) { + return null + } + return { + 'X-CSRFToken': csrfToken, + } +} + +export function postJsonOpts(data: object): RequestInit { + return { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...maybeCsrfToken(), + }, + body: JSON.stringify(data), + credentials: 'same-origin', + } +} + +export function isValidFnr(data: string): boolean { + return validator.idnr(data).status === 'valid' +} diff --git a/greg/migrations/0005_person_user.py b/greg/migrations/0005_person_user.py new file mode 100644 index 0000000000000000000000000000000000000000..bcdd509012f525b71f5343a5333e09573f0f797c --- /dev/null +++ b/greg/migrations/0005_person_user.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.7 on 2021-09-22 06:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("greg", "0004_ou_deleted_active"), + ] + + operations = [ + migrations.AddField( + model_name="person", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="user", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/greg/models.py b/greg/models.py index 8e1e59fc795328ac590d0fdec0eb7e4f1e87cf6a..363cb2cd5e17b3e2d3bc2a28e01c95187418c99a 100644 --- a/greg/models.py +++ b/greg/models.py @@ -1,6 +1,7 @@ from datetime import date from dirtyfields import DirtyFieldsMixin +from django.conf import settings from django.db import models from django.db.models import Lookup from django.db.models.fields import Field @@ -42,6 +43,13 @@ class Person(BaseModel): mobile_phone_verified_date = models.DateField(null=True) registration_completed_date = models.DateField(null=True) token = models.CharField(max_length=32, blank=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="user", + null=True, + blank=True, + ) def __str__(self): return "{} {} ({})".format(self.first_name, self.last_name, self.pk) diff --git a/gregsite/settings/base.py b/gregsite/settings/base.py index 758c3ed9db51bb3632d2b35bb5000a2d7b6f610f..2fd4a30a2c2dd1728464fe436c1f147a049ad04f 100644 --- a/gregsite/settings/base.py +++ b/gregsite/settings/base.py @@ -56,15 +56,30 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "sesame.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "gregsite.middleware.revision_user_middleware.RevisionUserMiddleware", ] +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", # default + "sesame.backends.ModelBackend", # link login +] + +SESAME_MAX_AGE = 600 # lifetime of token in seconds +SESSION_COOKIE_AGE = 1800 # lifetime of session in seconds + + +CSRF_COOKIE_SAMESITE = "Strict" +SESSION_COOKIE_SAMESITE = "Strict" +CSRF_COOKIE_HTTPONLY = True +SESSION_COOKIE_HTTPONLY = True + REST_FRAMEWORK = { "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", "DEFAULT_VERSION": "v1", - "ALLOWED_VERSIONS": ("v1",), + "ALLOWED_VERSIONS": ("v1", "gregui-v1"), "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.TokenAuthentication", "rest_framework.authentication.SessionAuthentication", diff --git a/gregsite/urls.py b/gregsite/urls.py index e0f9859117293eb3708c6123a95cc8c0802b9e4a..e122ba1cd165eb494e7ffdd1c1830ee598ea46f3 100644 --- a/gregsite/urls.py +++ b/gregsite/urls.py @@ -17,10 +17,12 @@ from django.contrib import admin from django.urls import include, path from greg import urls as greg_urls +from gregui import urls as ui_urls admin.autodiscover() urlpatterns = [ path("admin/", admin.site.urls), path("", include(greg_urls.urlpatterns)), + path("", include(ui_urls.urlpatterns)), ] diff --git a/gregui/api/__init__.py b/gregui/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/gregui/api/serializers/__init__.py b/gregui/api/serializers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/gregui/api/serializers/guest.py b/gregui/api/serializers/guest.py new file mode 100644 index 0000000000000000000000000000000000000000..ecdb652c5de590faede392fc0c269c44b9755df0 --- /dev/null +++ b/gregui/api/serializers/guest.py @@ -0,0 +1,21 @@ +from rest_framework import serializers + +from greg.models import Person + + +class GuestRegisterSerializer(serializers.ModelSerializer): + def create(self, validated_data): + obj = super().create(validated_data) + return obj + + class Meta: + model = Person + fields = ("id", "first_name", "last_name", "date_of_birth") + read_only_fields = ("id",) + extra_kwargs = { + "first_name": {"required": True}, + "last_name": {"required": True}, + "date_of_birth": {"required": True}, + # 'email': {'required': True}, + # 'phone_number': {'required': True}, + } diff --git a/gregui/api/urls.py b/gregui/api/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..0d39bbd34b782108f49262803e7722186bba22a7 --- /dev/null +++ b/gregui/api/urls.py @@ -0,0 +1,12 @@ +from django.urls import re_path + +from rest_framework.routers import DefaultRouter + +from gregui.api.views.guest import GuestRegisterView + +router = DefaultRouter(trailing_slash=False) + +urlpatterns = router.urls +urlpatterns += [ + re_path(r"register/$", GuestRegisterView.as_view(), name="guest-register"), +] diff --git a/gregui/api/views/__init__,py b/gregui/api/views/__init__,py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/gregui/api/views/guest.py b/gregui/api/views/guest.py new file mode 100644 index 0000000000000000000000000000000000000000..4d9370203b94acd1563b9a8a1669964841721cc1 --- /dev/null +++ b/gregui/api/views/guest.py @@ -0,0 +1,10 @@ +from rest_framework import viewsets, filters, permissions +from rest_framework.generics import CreateAPIView + +from greg.models import Person +from gregui.api.serializers.guest import GuestRegisterSerializer + +class GuestRegisterView(CreateAPIView): + queryset = Person.objects.all() + permission_classes = [permissions.AllowAny] + serializer_class = GuestRegisterSerializer \ No newline at end of file diff --git a/gregui/apps.py b/gregui/apps.py index a0a5e660bdae6f5877d56677541ea8294a2b6db4..21bea517dfe981a67c6f3980d77d99dc6ee1b30b 100644 --- a/gregui/apps.py +++ b/gregui/apps.py @@ -2,5 +2,5 @@ from django.apps import AppConfig class GreguiConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'gregui' + default_auto_field = "django.db.models.BigAutoField" + name = "gregui" diff --git a/gregui/tests.py b/gregui/tests.py deleted file mode 100644 index 7ce503c2dd97ba78597f6ff6e4393132753573f6..0000000000000000000000000000000000000000 --- a/gregui/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/gregui/tests/__init__.py b/gregui/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/gregui/tests/api/__init__.py b/gregui/tests/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/gregui/tests/api/test_guest.py b/gregui/tests/api/test_guest.py new file mode 100644 index 0000000000000000000000000000000000000000..17102f7634fb8973f86a34d8e41715bb930025d4 --- /dev/null +++ b/gregui/tests/api/test_guest.py @@ -0,0 +1,13 @@ +import pytest + +from rest_framework import status +from rest_framework.reverse import reverse + + +@pytest.mark.django_db +def test_register_guest(client): + data = {"first_name": "Foo", "last_name": "Bar", "date_of_birth": "2020-09-21"} + url = reverse("gregui-v1:guest-register") + response = client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == {"id": 1, **data} diff --git a/gregui/tests/conftest.py b/gregui/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..a59673226660f89957c51362bfc0499d0b546832 --- /dev/null +++ b/gregui/tests/conftest.py @@ -0,0 +1,22 @@ +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model + +import pytest + +from greg.models import ( + Consent, + Notification, + Person, + Sponsor, + SponsorOrganizationalUnit, + Identity, + Role, + RoleType, + OrganizationalUnit, + ConsentType, +) + +@pytest.fixture +def client() -> APIClient: + client = APIClient() + return client diff --git a/gregui/urls.py b/gregui/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..33070a391fb0b0cfec31461bfc2cbc5bdff826e0 --- /dev/null +++ b/gregui/urls.py @@ -0,0 +1,21 @@ +from typing import List + +from django.urls import include +from django.urls import path +from django.urls.resolvers import URLResolver + +from gregui.api import urls as api_urls +from gregui.views import TokenCreationView +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/whoami/", views.WhoAmIView.as_view(), name="api-whoami"), + path("api/ui/v1/token/<email>", TokenCreationView.as_view()), +] diff --git a/gregui/views.py b/gregui/views.py index 91ea44a218fbd2f408430959283f0419c921093e..97d2cde1f2c765bf94921719e565d568802998af 100644 --- a/gregui/views.py +++ b/gregui/views.py @@ -1,3 +1,104 @@ -from django.shortcuts import render +from django.contrib.auth import get_user_model +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 import status +from rest_framework.authentication import SessionAuthentication, BasicAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from sesame.utils import get_query_string -# Create your views here. +from greg.models import Person + + +class TokenCreationView(APIView): + """Token creation endpoint""" + + # Allow anyone to request a new query string to be sent to them + permission_classes = [] + + def _get_username(self, user_model, person: Person) -> str: + """Find a free username in the database for a person""" + counter = 1 + while True: + username = person.first_name[:3] + person.last_name[:3] + str(counter) + if not user_model.objects.filter(username=username).exists(): + return username + counter += 1 + + def post(self, request, *args, **kwargs): + """ + Send email to Person with querystring for login + + Persons without a user will have one created for them. + """ + email = self.kwargs["email"] + try: + person = Person.objects.get(email=email) + except Person.DoesNotExist: + # Exit if no person with that email (make sure to exit same way as when + # person does exist to not leak information) + return Response(status=status.HTTP_200_OK) + + # Create user if person does not have one or fetch existing + if not person.user: + user_model = get_user_model() + username = self._get_username(user_model, person) + user = user_model.objects.create(username=username) + person.user = user + person.save() + else: + user = person.user + + # Create querystring and send email + querystring = get_query_string(user) + # TODO: send email with query string + print(querystring) + + return Response(status=status.HTTP_200_OK) + + +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/") + + +class SessionView(APIView): + authentication_classes = [SessionAuthentication, BasicAuthentication] + permission_classes = [IsAuthenticated] + + @staticmethod + def get(request, format=None): + return JsonResponse({"isAuthenticated": True}) + + +class WhoAmIView(APIView): + authentication_classes = [SessionAuthentication, BasicAuthentication] + permission_classes = [IsAuthenticated] + + @staticmethod + def get(request, format=None): + return JsonResponse({"username": request.user.username}) diff --git a/poetry.lock b/poetry.lock index ff64d048394a119373a4f00f5b6f4b5b5ac9875d..4b7b8a531cd336f555f4e756d941fd0defe27f69 100644 --- a/poetry.lock +++ b/poetry.lock @@ -240,6 +240,21 @@ python-versions = ">=3.6" [package.dependencies] django = ">=2.0" +[[package]] +name = "django-sesame" +version = "2.4" +description = "Frictionless authentication with \"Magic Links\" for your Django project." +category = "main" +optional = false +python-versions = ">=3.6,<4.0" + +[package.dependencies] +django = ">=2.2" +ua-parser = {version = ">=0.10", optional = true, markers = "extra == \"ua\""} + +[package.extras] +ua = ["ua-parser (>=0.10)"] + [[package]] name = "django-stubs" version = "1.9.0" @@ -1007,6 +1022,14 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "ua-parser" +version = "0.10.0" +description = "Python port of Browserscope's user agent parser" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "uritemplate" version = "3.0.1" @@ -1058,7 +1081,7 @@ python-versions = "*" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "75c30a6f1bcd72a8d9725dbe92509be0fab8bf9e498ceaaf0a730f2337f439b0" +content-hash = "4854aed8a90a9f2308063cc6e8151623de62b9bed714c49a8fde3e66e7165d86" [metadata.files] appnope = [ @@ -1195,6 +1218,10 @@ django-reversion = [ {file = "django-reversion-4.0.0.tar.gz", hash = "sha256:ad6d714b4b9b824e22b88d47201cc0f74b5c4294c8d4e1f8d7ac7c3631ef3188"}, {file = "django_reversion-4.0.0-py3-none-any.whl", hash = "sha256:f059c654e38c0dd8dccd7f0990aa2f6d9ad22dab55c5e095f9596aeda8079dcd"}, ] +django-sesame = [ + {file = "django-sesame-2.4.tar.gz", hash = "sha256:4f232ba1c3642b4eef23257af9a2feac36970457d2940f4083c48c6a3d9cb0a3"}, + {file = "django_sesame-2.4-py3-none-any.whl", hash = "sha256:b0570b610ec2f3602ff12d43b5bca41c4a37a3079e6a0170ceccc69689446b87"}, +] django-stubs = [ {file = "django-stubs-1.9.0.tar.gz", hash = "sha256:664843091636a917faf5256d028476559dc360fdef9050b6df87ab61b21607bf"}, {file = "django_stubs-1.9.0-py3-none-any.whl", hash = "sha256:59c9f81af64d214b1954eaf90f037778c8d2b9c2de946a3cda177fefcf588fbd"}, @@ -1654,6 +1681,10 @@ typing-extensions = [ {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, ] +ua-parser = [ + {file = "ua-parser-0.10.0.tar.gz", hash = "sha256:47b1782ed130d890018d983fac37c2a80799d9e0b9c532e734c67cf70f185033"}, + {file = "ua_parser-0.10.0-py2.py3-none-any.whl", hash = "sha256:46ab2e383c01dbd2ab284991b87d624a26a08f72da4d7d413f5bfab8b9036f8a"}, +] uritemplate = [ {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, {file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"}, diff --git a/pyproject.toml b/pyproject.toml index 9af43a411dab7960a20861cd13135f5b29e4624b..c6c6c13e52494c91b840e4bcb33d7c0527b137ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ python-json-logger = "*" sentry-sdk = "*" whitenoise = "*" django-reversion = "*" +django-sesame = {extras = ["ua"], version = "^2.4"} [tool.poetry.dev-dependencies] Faker = "*"