diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..d19ebbdf3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), + +## [Unreleased] + +## [1.1.0] - 2020-08-25 +### Added +- Support for authorization code generation for GDPR API related calls (profile download and deletion) [#108](https://github.com/City-of-Helsinki/open-city-profile-ui/pull/108) +- Link to authentication method account management [#114](https://github.com/City-of-Helsinki/open-city-profile-ui/pull/114) +- Managing multiple addresses +- Managing multiple phone numbers +- Favicon + +### Changed +- Better postal code validation +- Removed drop shadows +- Replaced custom select boxes with HDS Dropdown + +### Fixed +- Focus indicator being partially hidden with elements used for downloading and deleting profile +- Notifications rendering on top of each other +- Order and visibility of language options +- Several issues with layout, scaling etc +- Text fixes + +## [1.0.0-rc.1] diff --git a/README.md b/README.md index 07aa5bdd7..ac1a246aa 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,20 @@ Since this app uses react-scripts (Create React App) the env-files work a bit di The following envs are used: -- REACT_APP_OIDC_AUTHORITY - this is the URL to tunnistamo -- REACT_APP_OIDC_CLIENT_ID - ID of the client that has to be configured in tunnistamo -- REACT_APP_PROFILE_AUDIENCE - name of the api-token that client uses profile-api with -- REACT_APP_PROFILE_GRAPHQL - URL to the profile graphql -- REACT_APP_OIDC_SCOPE - which scopes the app requires -- REACT_APP_SENTRY_DSN - sentry public dns-key +| Name | Description | +| --- | ------------- | +| `REACT_APP_HELSINKI_ACCOUNT_AMR` | Authentication method reference for Helsinki account.
**default:** `helusername` | +| `REACT_APP_IPD_MANAGEMENT_URL_HELSINKI_ACCOUNT` | Account management url for Helsinki account.
**default:** `https://salasana.hel.ninja/auth/realms/helsinki-salasana/account` | +| `REACT_APP_IPD_MANAGEMENT_URL_GITHUB` | Account management url for GitHub.
**default:** `https://github.com/settings/profile` | +| `REACT_APP_IPD_MANAGEMENT_URL_GOOGLE` | Account management url for Google.
**default:** `https://myaccount.google.com` | +| `REACT_APP_IPD_MANAGEMENT_URL_FACEBOOK` | Account management url for Facebook.
**default:** `http://facebook.com/settings` | +| `REACT_APP_IPD_MANAGEMENT_URL_YLE` | Account management url for Yle.
**default:** `https://tunnus.yle.fi/#omat-tiedot` | +| `REACT_APP_OIDC_AUTHORITY` | This is the URL to tunnistamo. | +| `REACT_APP_OIDC_CLIENT_ID` | ID of the client that has to be configured in tunnistamo. | +| `REACT_APP_OIDC_SCOPE` | Which scopes the app requires. | +| `REACT_APP_PROFILE_AUDIENCE` | Name of the api-token that client uses profile-api with. | +| `REACT_APP_PROFILE_GRAPHQL` | URL to the profile graphql. | +| `REACT_APP_SENTRY_DSN` | Sentry public dns-key. | ## Setting up local development environment with Docker @@ -142,6 +150,8 @@ The graphql-backend for development is located at https://profiili-api.test.kuva ## Learn More +To learn more about specific choices in this repository, you can browse the [docs](/docs). + You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). To learn React, check out the [React documentation](https://reactjs.org/). diff --git a/docs/gdpr-api-authorization.md b/docs/gdpr-api-authorization.md new file mode 100644 index 000000000..69347b12b --- /dev/null +++ b/docs/gdpr-api-authorization.md @@ -0,0 +1,80 @@ +# GDPR API compatibility + +The GDPR API requires the user to allow actions on their data. Let's take downloading profile data as an example. + +1) User clicks the download button +2) User is redirected to Tunnistamo which, if necessary, renders an UI the user can use to allow a set of permissions +3) User is redirected back to Helsinki profile UI and the download action is completed + +In essence, we need to request the authorization code in the UI, because the user flow may contain a step that requires user input. Once this code is generated, it must be provided within the download profile query and delete profile mutation when these requests are sent to the profile backend. The backend can then use this code to make requests to all the other services for data or deletion. + +## Technical explanation + +This flow introduces one difficult step--the exit and re-entry into the profile UI application. This makes the download and deletion code flows more difficult to handle. In comparison, these actions were previously completed with callbacks and promises--which make use of the fact that the "same SPA session" is retained throughout the user action. After we transition into fetching the authorization code, this assumption no longer holds, but instead the application is "hard refreshed" at least once. + +Within this application, this behaviour has been managed with the help of `GdprAuthorizationCodeManager`, `useActionResumer` and `useAuthorizationCode`. + +### `GdprAuthorizationCodeManager` + +This class is responsible for compliance with the `OpenID` protocol. It's responsible for handling the authorization flow. In this capacity it: +* Stores the application state so that it can be reused when authorization is complete +* Creates the authorization url +* Navigates to authorization url +* Interjects the authorization callback +* Saves code for use +* Reloads application state +* Deletes code and application state from store when it is no longer needed + +### `useActionResumer` + +This hook is an abstraction which seeks to bridge the "gap" that forms when the user is redirected to Tunnistamo and then finally back into our application. It allows other code within the application to complete actions that span a page refresh while being relatively agnostic about the method by which the application knows what action to resume when the redirection is done. + +**Parameters** + +| Name | Description | +| ------------- | ------------- | +| **`deferredAction`** | Name of action that get _deferred_ until the redirect back into the UI. This is used as an ID which tells `useActionResumer` whether it should run the `callback` parameter. | +| **`onActionInitialization`** | `useActionResumer` begins the action flow by calling this function. | +| **`callback`** | `useActionResumer`** calls this function when the action is resumed. | + +**Humanized explanation** +`useActionResumer` provides its user with the `startAction` function. This function can be used to invoke an action that persists over page reloads. `useActionResumer` listens with a `useEffect` in order to notice when it should complete an action. When it determines that it's a suitable time, it invokes `callback`. + +**Note:** +Currently `useActionResumer` relies on a search parameter to know whether it should invoke a `callback`. The `useEffect` that it uses to determine when an action should be completed is not hooked up to listen to changes in location. This way `useActionResumer` won't work unless the search parameter invoking it is present already when the component calling it is mounted. If the search parameter becomes available after component mount, the callback won't be invoked. Again from another perspective: `useActionResumer` uses the global `location` object to determine whether a search parameter is present. This means that it won't react to location changes completed through react-router for instance. + +Using the global location is a sort of anti-pattern which would make it more risky to transition this application into a server rendered application for instance. + +### `useAuthorizationCode` +Combines `GdprAuthorizationCodeManager` and `useActionResumer` into a single API that's easier to consume. Code that needs access to an authorization code can hook up to one with a call like this: + +``` + const [ + startFetchingAuthorizationCode, + isAuthorizing + ] = useAuthorizationCode( + 'useDownloadProfile', + handleAuthorizationCodeCallback + ); +``` + +## Technical flow + +Here I've explained how the application should act in more technical detail. Developers can make use of this explanation to get a better sense of how the features relying on `authorization code` should work. I'll take the download flow as an example, but the delete flow is mostly the same. + +1) User logs in +2) User expands panel for downloading user profile +3) User clicks download button + 1) Download button is disabled and its label is changed + 1) Current url and the download action are saved into local storage under `kuvaGdprAuthManager` prefix that's tailed by a random UUID + 1) Tunnistamo authorize URL is built + 1) User is redirected to Tunnistamo +6) User allows access to personal information in Tunnistamo +7) User is redirected back into the application into address `/gdpr-callback` + 1) It calls `GdprAuthorizationCodeManager.authorizationTokenFetchCallback` + 1) Token is saved into localStorage ready for consumption. + 1) Previous app state is restored. User is redirected to the page the invoked download on and a special search parameter is added to tell `useActionResumer` instances that the one with this id should fire its callback. + 1) Previous app state is cleared +8) User lands back on the profile index page based on the redirect. + 1) The code is consumed--it's requested from `GdprAuthorizationCodeManager` which then clears it from its memory (localStorage). + 1) The code is used to call `downloadProfile` diff --git a/package.json b/package.json index ab66a457a..973708ac1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-city-profile-ui", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "private": true, "dependencies": { @@ -21,6 +21,7 @@ "@types/react-modal": "^3.10.1", "@types/react-redux": "^7.1.5", "@types/react-router-dom": "^5.1.0", + "@types/uuid": "^8.0.0", "@types/validator": "^13.0.0", "@types/yup": "^0.26.24", "apollo-boost": "^0.4.4", @@ -28,7 +29,7 @@ "date-fns": "^2.9.0", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.2", - "enzyme-to-json": "^3.4.4", + "enzyme-to-json": "^3.5.0", "file-saver": "^2.0.2", "formik": "^2.0.4", "graphql": "^14.5.8", @@ -39,7 +40,7 @@ "i18n-iso-countries": "^5.3.0", "i18next": "^17.3.0", "i18next-browser-languagedetector": "^4.0.1", - "lodash": "^4.17.15", + "lodash": "^4.17.19", "oidc-client": "^1.9.1", "react": "^16.11.0", "react-dom": "^16.11.0", @@ -53,6 +54,7 @@ "redux-oidc": "^3.1.5", "redux-starter-kit": "^1.0.0", "typescript": "^3.7.3", + "uuid": "^8.1.0", "validator": "^13.0.0", "yup": "^0.27.0" }, diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 000000000..037994906 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html index d57aa728f..062d6a920 100644 --- a/public/index.html +++ b/public/index.html @@ -6,7 +6,7 @@ Profiili @@ -14,5 +14,6 @@
+
diff --git a/src/App.tsx b/src/App.tsx index b69a16977..793169aad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,9 +23,10 @@ import AccessibilityStatement from './accessibilityStatement/AccessibilityStatem import { MAIN_CONTENT_ID } from './common/constants'; import AccessibilityShortcuts from './common/accessibilityShortcuts/AccessibilityShortcuts'; import AppMeta from './AppMeta'; -import authenticate from './auth/authenticate'; -import logout from './auth/logout'; +import useAuthenticate from './auth/useAuthenticate'; import authConstants from './auth/constants/authConstants'; +import GdprAuthorizationCodeManagerCallback from './gdprApi/GdprAuthorizationCodeManagerCallback'; +import ToastProvider from './toast/ToastProvider'; countries.registerLocale(fi); countries.registerLocale(en); @@ -57,6 +58,7 @@ type Props = {}; function App(props: Props) { const location = useLocation(); + const [authenticate, logout] = useAuthenticate(); if (location.pathname === '/loginsso') { authenticate(); @@ -82,33 +84,38 @@ function App(props: Props) { - - - {/* This should be the first focusable element */} - - - - - - - - - - - - - - - - - - - 404 - not found - - + + + + {/* This should be the first focusable element */} + + + + + + + + + + + + + + + + + + + + + + 404 - not found + + + diff --git a/src/accessibilityStatement/AccessibilityStatementEn.tsx b/src/accessibilityStatement/AccessibilityStatementEn.tsx index 017760d4c..dcd2dec44 100644 --- a/src/accessibilityStatement/AccessibilityStatementEn.tsx +++ b/src/accessibilityStatement/AccessibilityStatementEn.tsx @@ -5,8 +5,8 @@ function AccessibilityStatementEn() {

Accessibility Statement

- This accessibility statement applies to the website Youth membership - registration. The site address is https://hel.fi/profiili + This accessibility statement applies to the Helsinki Profile website. + The site address is https://hel.fi/profiili

Statutory provisions applicable to the website

diff --git a/src/accessibilityStatement/AccessibilityStatementSv.tsx b/src/accessibilityStatement/AccessibilityStatementSv.tsx index 319dce546..2c9abaceb 100644 --- a/src/accessibilityStatement/AccessibilityStatementSv.tsx +++ b/src/accessibilityStatement/AccessibilityStatementSv.tsx @@ -6,8 +6,7 @@ function AccessibilityStatementSv() {

Tillgänglighetsutlåtande

Detta tillgänglighetsutlåtande gäller Helsingfors stads webbplats - Ungdomstjänsternas medlem ansökan . Webbplatsens adress är - https://hel.fi/profiili + Helsingfors-profil. Webbplatsens adress är https://hel.fi/profiili

Lagbestämmelser som gäller webbplatsen

diff --git a/src/auth/authenticate.ts b/src/auth/authenticate.ts deleted file mode 100644 index 73383aef3..000000000 --- a/src/auth/authenticate.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as Sentry from '@sentry/browser'; - -import userManager from './userManager'; -import store from '../redux/store'; -import { apiError } from './redux'; - -export default function(): void { - userManager.signinRedirect().catch(error => { - if (error.message !== 'Network Error') { - Sentry.captureException(error); - } - store.dispatch(apiError(error.toString())); - }); -} diff --git a/src/auth/components/login/Login.module.css b/src/auth/components/login/Login.module.css index 22daea664..c2511d9b0 100644 --- a/src/auth/components/login/Login.module.css +++ b/src/auth/components/login/Login.module.css @@ -12,27 +12,46 @@ width: 95%; text-align: center; color: var(--color-white); + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; } .logo { width: 170px; height: 80px; + background-color: var(--color-white); } -.logo g { - fill: var(--color-white); +button.button { + margin-top: 50px; + background-color: var(--color-white); + width: 100%; } -.button { - margin-top: 50px; +button.button:hover { + background-color: var(--color-background-button-secondary-hover); +} + +.content h1 { + font-size: var(--fontsize-h-2); } .content h2 { + line-height: var(--lineheight-l); font-size: var(--fontsize-h-5); } -.content button { - min-width: auto; +@media(min-width: 450px) { + button.button { + width: 230px; + } + + .content h1 { + font-size: var(--fontsize-h-1); + } + } @media (min-width: 600px) { @@ -41,8 +60,4 @@ } } -@media (min-width: 230px) { - .content button { - min-width: 230px; - } -} + diff --git a/src/auth/components/login/Login.tsx b/src/auth/components/login/Login.tsx index 74e67e157..ba0519d74 100644 --- a/src/auth/components/login/Login.tsx +++ b/src/auth/components/login/Login.tsx @@ -2,15 +2,14 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { connect } from 'react-redux'; import { useMatomo } from '@datapunt/matomo-tracker-react'; +import { Button } from 'hds-react'; import { RootState } from '../../../redux/rootReducer'; import { AuthState, resetApiError } from '../../redux'; -import { ReactComponent as HelsinkiLogo } from '../../../common/svg/HelsinkiLogo.svg'; +import HelsinkiLogo from '../../../common/helsinkiLogo/HelsinkiLogo'; import styles from './Login.module.css'; -import authenticate from '../../authenticate'; import PageLayout from '../../../common/pageLayout/PageLayout'; -import Button from '../../../common/button/Button'; -import NotificationComponent from '../../../common/notification/NotificationComponent'; +import useAuthenticate from '../../../auth/useAuthenticate'; type Props = { auth: AuthState; @@ -20,16 +19,17 @@ type Props = { function Home(props: Props) { const { t } = useTranslation(); const { trackEvent } = useMatomo(); + const [authenticate] = useAuthenticate(); return (
- +

{t('login.title')}

{t('login.description')}

- props.resetApiError()} - />
); } diff --git a/src/auth/logout.ts b/src/auth/logout.ts deleted file mode 100644 index 36959b79e..000000000 --- a/src/auth/logout.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as Sentry from '@sentry/browser'; - -import authConstants from './constants/authConstants'; -import userManager from './userManager'; -import store from '../redux/store'; -import { apiError } from './redux'; - -export default function(): void { - window.localStorage.removeItem(authConstants.OIDC_KEY); - userManager.signoutRedirect().catch(error => { - Sentry.captureException(error); - store.dispatch(apiError(error.toString())); - }); -} diff --git a/src/auth/useAuthenticate.ts b/src/auth/useAuthenticate.ts new file mode 100644 index 000000000..d9b55aefc --- /dev/null +++ b/src/auth/useAuthenticate.ts @@ -0,0 +1,32 @@ +import React from 'react'; +import * as Sentry from '@sentry/browser'; + +import useToast from '../toast/useToast'; +import userManager from './userManager'; +import authConstants from './constants/authConstants'; + +function useAuthenticate() { + const { createToast } = useToast(); + + const authenticate = React.useCallback(() => { + userManager.signinRedirect().catch(error => { + if (error.message !== 'Network Error') { + Sentry.captureException(error); + } + + createToast({ type: 'error' }); + }); + }, [createToast]); + + const logout = React.useCallback(() => { + window.localStorage.removeItem(authConstants.OIDC_KEY); + userManager.signoutRedirect().catch(error => { + Sentry.captureException(error); + createToast({ type: 'error' }); + }); + }, [createToast]); + + return [authenticate, logout]; +} + +export default useAuthenticate; diff --git a/src/auth/useProfile.ts b/src/auth/useProfile.ts new file mode 100644 index 000000000..a5ef5adc9 --- /dev/null +++ b/src/auth/useProfile.ts @@ -0,0 +1,87 @@ +import React from 'react'; + +import getAuthenticatedUser from './getAuthenticatedUser'; +import config from '../config'; + +export type AMR = + | 'github' + | 'google' + | 'facebook' + | 'yle' + | typeof config.helsinkiAccountAMR; + +export type AMRStatic = + | 'github' + | 'google' + | 'facebook' + | 'yle' + | 'helsinkiAccount'; + +export interface Profile { + amr: AMR; + auth_time: number; + email: string; + email_verified: boolean; + family_name: string; + given_name: string; + name: string; + nickname: string; + sub: string; +} + +interface ProfileState { + profile: Profile | null; + loading: boolean; + error: Error | null; +} + +function useProfile(): ProfileState { + const [profile, setProfile] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let ignore = false; + + function getUser() { + setIsLoading(true); + + getAuthenticatedUser() + .then(user => { + if (ignore) { + return; + } + + setProfile(user.profile); + }) + .catch(() => { + if (ignore) { + return; + } + + setError(Error('User was not found')); + }) + .finally(() => { + if (ignore) { + return; + } + + setIsLoading(false); + }); + } + + getUser(); + + return () => { + ignore = true; + }; + }, []); + + return { + profile, + loading: isLoading, + error, + }; +} + +export default useProfile; diff --git a/src/common/expandingPanel/ExpandingPanel.module.css b/src/common/expandingPanel/ExpandingPanel.module.css index 19d6bcb3a..a8aac5649 100644 --- a/src/common/expandingPanel/ExpandingPanel.module.css +++ b/src/common/expandingPanel/ExpandingPanel.module.css @@ -4,9 +4,7 @@ --ep-background: var(--color-white); --ep-accent-color: var(--color-bus); width: 100%; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2); background: var(--ep-background); - margin: 0 0 var(--spacing-m) 0; } .title { @@ -68,8 +66,6 @@ /* title in order to avoid focus outline obstructing content. */ margin-top: calc(-1 * var(--ep-vertical-whitespace)); background: var(--ep-background); - position: relative; - z-index: 1; } @media (max-width: 400px) { diff --git a/src/common/expandingPanel/ExpandingPanel.tsx b/src/common/expandingPanel/ExpandingPanel.tsx index d0629090c..f357721eb 100644 --- a/src/common/expandingPanel/ExpandingPanel.tsx +++ b/src/common/expandingPanel/ExpandingPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, PropsWithChildren } from 'react'; +import React, { useState, PropsWithChildren, useRef } from 'react'; import { IconAngleRight } from 'hds-react'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; @@ -8,10 +8,19 @@ import styles from './ExpandingPanel.module.css'; type Props = PropsWithChildren<{ title?: string; showInformationText?: boolean; + defaultExpanded?: boolean; + scrollIntoViewOnMount?: boolean; }>; -function ExpandingPanel(props: Props) { - const [expanded, setExpanded] = useState(false); +function ExpandingPanel({ + children, + defaultExpanded, + showInformationText, + scrollIntoViewOnMount, + title, +}: Props) { + const container = useRef(null); + const [expanded, setExpanded] = useState(defaultExpanded); const toggleExpanding = () => setExpanded(prevState => !prevState); const { t } = useTranslation(); const onKeyDown = (event: React.KeyboardEvent) => { @@ -25,19 +34,34 @@ function ExpandingPanel(props: Props) { } }; + const handleContainerRef = (ref: HTMLDivElement) => { + // If ref is not saved yet we are about in the first render. + // In that case we can scroll this element into view. + if (!container.current && scrollIntoViewOnMount && ref) { + ref.scrollIntoView(); + } + + container.current = ref; + }; + + const handleContentClick = (event: React.SyntheticEvent) => { + event.stopPropagation(); + }; + return ( -
-
-

{props.title}

+
+
+

{title}

- {props.showInformationText && ( + {showInformationText && (

{expanded ? t('expandingPanel.hideInformation') @@ -52,7 +76,11 @@ function ExpandingPanel(props: Props) { />

- {expanded &&
{props.children}
} + {expanded && ( +
+ {children} +
+ )}
); } diff --git a/src/common/explanation/Explanation.module.css b/src/common/explanation/Explanation.module.css index 45bacf6ce..b8c752503 100644 --- a/src/common/explanation/Explanation.module.css +++ b/src/common/explanation/Explanation.module.css @@ -11,10 +11,10 @@ margin-bottom: 30px; } .main.h2 { - font-size: var(--h2-fontsize); + font-size: var(--fontsize-h-3); } .main.h3 { - font-size: var(--h3-fontsize); + font-size: var(--fontsize-h-4); } .small { margin-top: 0; @@ -22,6 +22,16 @@ font-size: 20px; } +@media (min-width: 450px) { + .main.h2 { + font-size: var(--fontsize-h-2); + } + + .main.h3 { + font-size: var(--fontsize-h-3); + } +} + @media (min-width: 1200px) { .container.margin { margin: 0; diff --git a/src/common/footer/Footer.module.css b/src/common/footer/Footer.module.css index c0b442a9e..1221e8cbc 100644 --- a/src/common/footer/Footer.module.css +++ b/src/common/footer/Footer.module.css @@ -2,13 +2,17 @@ background: var(--color-bus); } -.logo { - height: 58px; - margin: 20px; +.content { + display: flex; + flex-direction: column; + width: auto; } -.logo g { - fill: var(--color-white); +.logo { + background-color: var(--color-white); + width: 120px; + height: 42px; + margin: 20px 10px; } .textContainer { @@ -32,18 +36,19 @@ font-weight: bold; color: var(--color-white); margin-top: 10px; + text-decoration: none; } @media (min-width: 1200px) { + .content { + margin: 0 auto; + width: 1140px; + } + .textContainer { margin: 0; padding: 20px 0 50px 3px; } - - .logo { - height: 84px; - margin: 40px 0 20px; - } } @media (min-width: 650px) { @@ -56,6 +61,12 @@ } } +@media (min-width: 1200px) { + .logo { + margin: 20px 0; + } +} + diff --git a/src/common/footer/Footer.tsx b/src/common/footer/Footer.tsx index 187af80ca..9dcb78f8e 100644 --- a/src/common/footer/Footer.tsx +++ b/src/common/footer/Footer.tsx @@ -1,10 +1,9 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as HelsinkiLogo } from '../svg/HelsinkiLogo.svg'; +import HelsinkiLogo from '../helsinkiLogo/HelsinkiLogo'; import Copyright from '../copyright/Copyright'; import styles from './Footer.module.css'; -import responsive from '../cssHelpers/responsive.module.css'; import FooterLinks from '../footerLinks/FooterLinks'; type Props = { @@ -15,10 +14,8 @@ function Footer(props: Props) { const { t } = useTranslation(); return (