diff --git a/backend/src/app.ts b/backend/src/app.ts index 91b6525d..2989468e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,6 +2,7 @@ import 'express-async-errors' import express from 'express' import cors from 'cors' import crossSearchRouter from './routes/crossSearch' +import statisticsRouter from './routes/statistics' import localityRouter from './routes/locality' import museumRouter from './routes/museum' import referenceRouter from './routes/reference' @@ -52,6 +53,7 @@ app.use('/crosssearch', crossSearchRouter) app.use('/locality', localityRouter) app.use('/locality-species', localitySpeciesRouter) app.use('/species', speciesRouter) +app.use('/statistics', statisticsRouter) app.use('/reference', referenceRouter) app.use('/time-unit', timeUnitRouter) app.use('/time-bound', requireOneOf([Role.Admin, Role.EditUnrestricted]), timeBoundRouter) diff --git a/backend/src/routes/person.ts b/backend/src/routes/person.ts index c8fd8255..2b9b81be 100644 --- a/backend/src/routes/person.ts +++ b/backend/src/routes/person.ts @@ -1,9 +1,16 @@ import { Router, Request } from 'express' import { getAllPersons, getPersonDetails, validateEntirePerson } from '../services/person' -import { Role, PersonDetailsType, EditDataType, EditMetaData } from '../../../frontend/src/shared/types' +import { + Role, + PersonDetailsType, + EditDataType, + EditMetaData, + userGroups, + UserGroup, +} from '../../../frontend/src/shared/types' import { requireOneOf } from '../middlewares/authorizer' import { writePerson } from '../services/write/person' -import { UserGroup, validUserGroups, writeUserGroup } from '../services/write/user' +import { writeUserGroup } from '../services/write/user' const router = Router() @@ -34,11 +41,9 @@ router.put( '/', async (req: Request & EditMetaData }>, res) => { const { ...editedPerson } = req.body.person - if (!editedPerson.initials) { return res.status(403).send({ error: 'Missing initials, creating new persons is not yet implemented' }) } - /* Access checking happens differently for this route, since we want to allow users to modify their own data */ if (!req.user) return res.status(401).send({ @@ -60,7 +65,7 @@ router.put( req.user.role === Role.Admin && editedPerson.user_id && editedPerson.now_user_group && - validUserGroups.includes(editedPerson.now_user_group) + userGroups.includes(editedPerson.now_user_group) ) { await writeUserGroup(editedPerson.user_id, editedPerson.now_user_group as UserGroup) } diff --git a/backend/src/routes/statistics.ts b/backend/src/routes/statistics.ts new file mode 100644 index 00000000..80f68cd8 --- /dev/null +++ b/backend/src/routes/statistics.ts @@ -0,0 +1,22 @@ +import { Router } from 'express' +import { getLocalityStatistics, getSpeciesStatistics, getStatistics } from '../services/statistics' +import { fixBigInt } from '../utils/common' + +const router = Router() + +router.get('/', async (_req, res) => { + const statistics = await getStatistics() + return res.status(200).send(fixBigInt(statistics)) +}) + +router.get('/locality', async (_req, res) => { + const statistics = await getLocalityStatistics() + return res.status(200).send(fixBigInt(statistics)) +}) + +router.get('/species', async (_req, res) => { + const statistics = await getSpeciesStatistics() + return res.status(200).send(fixBigInt(statistics)) +}) + +export default router diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index 920da3be..98884c0c 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -1,14 +1,17 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { Router } from 'express' +import { Request, Router } from 'express' import { sign } from 'jsonwebtoken' import { SECRET, LOGIN_VALID_SECONDS } from '../utils/config' import * as bcrypt from 'bcrypt' import { nowDb } from '../utils/db' import { getRole } from '../middlewares/authenticator' import { AccessError } from '../middlewares/authorizer' -import { Role } from '../../../frontend/src/shared/types' +import { Role, UserDetailsType } from '../../../frontend/src/shared/types' import md5 from 'md5' import { validatePassword } from '../utils/validatePassword' +import { createPasswordHash } from '../utils/createPasswordHash' +import { writeUser } from '../services/write/user' +import { validateUser } from '../utils/validateUser' const router = Router() @@ -75,11 +78,7 @@ router.post('/login', async (req, res) => { }) }) -const createPasswordHash = async (password: string) => { - const saltRounds = 10 - return await bcrypt.hash(password, saltRounds) -} - +// TODO: Remove if not needed for anything router.post('/create', async (req, res) => { const { username, password, initials, role, secret } = req.body @@ -137,6 +136,33 @@ router.put('/password', async (req, res) => { return res.status(200).send() }) -router.post('/create') +router.post('/new', async (req: Request, res) => { + const user: UserDetailsType = req.body + if (!user) return res.status(400).send({ message: 'Missing user' }) + + if (!user.initials) { + return res.status(403).send({ message: 'Missing initials' }) + } + + if (!req.user || req.user.role !== Role.Admin) + return res.status(401).send({ + message: 'User not authorized for the requested resource or action.', + }) + + const error = await validateUser(user) + if (error) + return res.status(400).send({ + message: error, + }) + + // All good, write user. + try { + await writeUser(user) + } catch (e) { + return res.status(500).send({ message: 'Unknown server error occurred' }) + } + + return res.status(200).send({ message: '' }) +}) export default router diff --git a/backend/src/services/locality.ts b/backend/src/services/locality.ts index 0807ce14..25878923 100644 --- a/backend/src/services/locality.ts +++ b/backend/src/services/locality.ts @@ -59,7 +59,7 @@ export const getAllLocalities = async (user?: User) => { return rest } - const result = (await nowDb.now_loc.findMany({ + const localityResult = (await nowDb.now_loc.findMany({ select: { lid: true, loc_name: true, @@ -117,6 +117,17 @@ export const getAllLocalities = async (user?: User) => { }, })) as LocalityPreFilter[] + const synonyms: { lid: number }[] = await nowDb.now_syn_loc.findMany({ + select: { lid: true }, + distinct: ['lid'], + }) + + const synonymIdSet = new Set(synonyms.map(s => s.lid)) + const result = localityResult.map(loc => ({ + ...loc, + has_synonym: synonymIdSet.has(loc.lid), + })) + if (showAll) return result.map(removeProjects) if (!user) return result.filter(loc => loc.loc_status === false).map(removeProjects) diff --git a/backend/src/services/species.ts b/backend/src/services/species.ts index a6227ad0..6d12e950 100644 --- a/backend/src/services/species.ts +++ b/backend/src/services/species.ts @@ -7,7 +7,7 @@ import { logDb, nowDb } from '../utils/db' import { getReferenceDetails } from './reference' export const getAllSpecies = async () => { - const result = await nowDb.com_species.findMany({ + const speciesResult = await nowDb.com_species.findMany({ select: { species_id: true, order_name: true, @@ -54,6 +54,31 @@ export const getAllSpecies = async () => { }, }) + const speciesWithLocality: { species_id: number }[] = await nowDb.now_ls.findMany({ + where: { + now_loc: { + NOT: undefined, + }, + }, + select: { + species_id: true, + }, + distinct: ['species_id'], + }) + + const synonyms: { species_id: number }[] = await nowDb.com_taxa_synonym.findMany({ + select: { species_id: true }, + distinct: ['species_id'], + }) + + const speciesWithLocalitySet = new Set(speciesWithLocality.map(s => s.species_id)) + const synonymIdSet = new Set(synonyms.map(s => s.species_id)) + const result = speciesResult.map(sp => ({ + ...sp, + has_synonym: synonymIdSet.has(sp.species_id), + has_no_locality: !speciesWithLocalitySet.has(sp.species_id), + })) + return result } @@ -69,13 +94,14 @@ export const getSpeciesDetails = async (id: number) => { now_sau: { include: { now_sr: { - include: { + select: { ref_ref: { - include: { + select: { ref_authors: true, ref_journal: true, }, }, + rid: true, }, }, }, diff --git a/backend/src/services/statistics.ts b/backend/src/services/statistics.ts new file mode 100644 index 00000000..a4a2f084 --- /dev/null +++ b/backend/src/services/statistics.ts @@ -0,0 +1,48 @@ +import { ActivityStatistic, Statistics } from '../../../frontend/src/shared/types' +import { Prisma } from '../../prisma/generated/now_test_client' +import { nowDb } from '../utils/db' + +export const getStatistics = async (): Promise => { + const localityAggregate = await nowDb.now_loc.aggregate({ + _count: { + lid: true, + }, + }) + const localityCount = localityAggregate._count.lid + + const speciesAggregate = await nowDb.com_species.aggregate({ + _count: { + species_id: true, + }, + }) + const speciesCount = speciesAggregate._count.species_id + + const localitySpeciesQuery = Prisma.sql` + SELECT COUNT(now_ls.species_id) as count + FROM now_loc + LEFT JOIN now_ls ON now_ls.lid = now_loc.lid + ` + + const localitySpeciesAggregate: { count: number }[] = await nowDb.$queryRaw(localitySpeciesQuery) + let localitySpeciesCount = 0 + + if (localitySpeciesAggregate.length > 0) { + localitySpeciesCount = localitySpeciesAggregate[0].count + } + + return { + localityCount, + speciesCount, + localitySpeciesCount, + } +} + +export const getLocalityStatistics = async (): Promise => { + const query = Prisma.sql`SELECT * FROM now_v_locality_statistics LIMIT 10` + return await nowDb.$queryRaw(query) +} + +export const getSpeciesStatistics = async (): Promise => { + const query = Prisma.sql`SELECT * FROM now_v_species_statistics LIMIT 10` + return await nowDb.$queryRaw(query) +} diff --git a/backend/src/services/write/person.ts b/backend/src/services/write/person.ts index 55246ff9..3e875a4f 100644 --- a/backend/src/services/write/person.ts +++ b/backend/src/services/write/person.ts @@ -1,6 +1,7 @@ import { EditDataType, PersonDetailsType } from '../../../../frontend/src/shared/types' import Prisma from '../../../prisma/generated/now_test_client' import { nowDb, getFieldsOfTables } from '../../utils/db' +import { getPersonDetails } from '../person' import { filterAllowedKeys } from './writeOperations/utils' export const writePerson = async (person: EditDataType) => { @@ -8,12 +9,24 @@ export const writePerson = async (person: EditDataType) => { const filteredPerson = filterAllowedKeys(person, allowedColumns) as Prisma.com_people let personId: string - if (!filteredPerson.initials) { - throw new Error('Missing initials, creating new persons is not yet implemented') + const isPerson = await getPersonDetails(filteredPerson.initials) + + if (!isPerson) { + const newPerson = await nowDb.com_people.create({ + data: { ...filteredPerson, full_name: `${filteredPerson.first_name} ${filteredPerson.surname}` }, + }) + personId = newPerson.initials } else { await nowDb.com_people.update({ where: { initials: person.initials }, - data: { ...filteredPerson, full_name: `${filteredPerson.first_name} ${filteredPerson.surname}` }, + data: { + first_name: filteredPerson.first_name, + surname: filteredPerson.surname, + email: filteredPerson.email, + organization: filteredPerson.organization, + country: filteredPerson.country, + full_name: `${filteredPerson.first_name} ${filteredPerson.surname}`, + }, }) personId = filteredPerson.initials } diff --git a/backend/src/services/write/user.ts b/backend/src/services/write/user.ts index c47a852a..2a18cc04 100644 --- a/backend/src/services/write/user.ts +++ b/backend/src/services/write/user.ts @@ -1,11 +1,30 @@ +import { UserDetailsType, UserGroup } from '../../../../frontend/src/shared/types' +import { createPasswordHash } from '../../utils/createPasswordHash' import { nowDb } from '../../utils/db' -export const validUserGroups = ['su', 'eu', 'er', 'pl', 'plp', 'no', 'ro'] -export type UserGroup = 'su' | 'eu' | 'er' | 'pl' | 'plp' | 'no' | 'ro' - export const writeUserGroup = async (user_id: number, group: UserGroup) => { await nowDb.com_users.update({ where: { user_id: user_id }, - data: { now_user_group: group }, + data: { now_user_group: group.split(' ')[0] }, }) } + +export const writeUser = async (user: UserDetailsType) => { + const passwordHash = await createPasswordHash(user.password!) + const createdUser = await nowDb.com_users.create({ + data: { + user_name: user.user_name, + newpassword: passwordHash, + now_user_group: user.now_user_group?.split(' ')[0], + }, + }) + + if (!createdUser || !createdUser.user_id) throw Error('Could not create user.') + + const createdPerson = await nowDb.com_people.update({ + where: { initials: user.initials }, + data: { user_id: createdUser.user_id }, + }) + + if (!createdPerson) throw Error("Could not update person's user ID.") +} diff --git a/backend/src/utils/createPasswordHash.ts b/backend/src/utils/createPasswordHash.ts new file mode 100644 index 00000000..40887333 --- /dev/null +++ b/backend/src/utils/createPasswordHash.ts @@ -0,0 +1,6 @@ +import * as bcrypt from 'bcrypt' + +export const createPasswordHash = async (password: string) => { + const saltRounds = 10 + return await bcrypt.hash(password, saltRounds) +} diff --git a/backend/src/utils/validateUser.ts b/backend/src/utils/validateUser.ts new file mode 100644 index 00000000..d6dc5b81 --- /dev/null +++ b/backend/src/utils/validateUser.ts @@ -0,0 +1,36 @@ +import { UserDetailsType } from '../../../frontend/src/shared/types' +import { nowDb } from './db' +import { validatePassword } from './validatePassword' + +const isUsernameTaken = async (username: string): Promise => + !!(await nowDb.com_users.findFirst({ where: { user_name: username } })) + +const personWithoutUserExists = async (initials: string): Promise => { + const person = await nowDb.com_people.findFirst({ where: { initials: initials } }) + if (!person) return false + if (person.user_id) { + const user = await nowDb.com_users.findFirst({ where: { user_id: person.user_id } }) + if (user) return false + } + return true +} + +export const validateUser = async (user: UserDetailsType): Promise => { + const usernameMinLength = 3 + const usernameMaxLength = 100 + + if (!user.user_name) return 'Username is missing.' + if (!user.password) return 'Password is missing.' + if (!user.now_user_group) return 'User group is missing.' + + if (user.user_name?.length < usernameMinLength || user.user_name?.length > usernameMaxLength) + return `Username must be between ${usernameMinLength} and ${usernameMaxLength} characters.` + + const res = validatePassword(user.password) + if (!res.isValid) return res.error ?? 'Password is invalid.' + + if (await isUsernameTaken(user.user_name)) return 'Username is already in use.' + if (!(await personWithoutUserExists(user.initials))) return "Person doesn't exist or person already has a user." + + return null +} diff --git a/cypress/e2e/locality.cy.js b/cypress/e2e/locality.cy.js index eee5fa41..ae68820c 100644 --- a/cypress/e2e/locality.cy.js +++ b/cypress/e2e/locality.cy.js @@ -295,6 +295,8 @@ describe("Locality's coordinate selection map works", () => { it('Map view and location search work', () => { cy.visit(`/locality/20920?tab=1`) cy.get('[id=edit-button]').click() + cy.contains('Choose a country').parent().type('Finland') + cy.contains('Finland').click() cy.contains('Get Coordinates').click() cy.contains('OpenStreetMap') cy.contains('Leaflet') diff --git a/cypress/e2e/person.cy.js b/cypress/e2e/person.cy.js index ef5911de..0fcbd1cc 100644 --- a/cypress/e2e/person.cy.js +++ b/cypress/e2e/person.cy.js @@ -35,11 +35,11 @@ describe('Editing a person', () => { cy.get('[id=edit-button]').click() - cy.contains('Choose user group').parent().type('n') - cy.contains('no').click() + cy.contains('Choose user group').parent().type('e') + cy.contains('er (edit restricted)').click() cy.get('[id=write-button]').click() - cy.contains('no') + cy.contains('er') }) it('with any missing fields does not work', () => { diff --git a/cypress/e2e/ui.cy.js b/cypress/e2e/ui.cy.js index a6cced52..154d940f 100644 --- a/cypress/e2e/ui.cy.js +++ b/cypress/e2e/ui.cy.js @@ -14,7 +14,7 @@ describe('Button Tests', () => { cy.contains('References').should('be.visible') cy.contains('Time Units').should('be.visible') - cy.contains('Cross-Search').should('be.visible') + cy.contains('Locality-Species').should('be.visible') cy.contains('Localities').should('be.visible') cy.contains('Species').should('be.visible') }) @@ -49,7 +49,7 @@ describe('Button Tests', () => { it('Cross Search button works', () => { cy.visit('/') - cy.contains('Cross-Search').click() + cy.contains('Locality-Species').click() cy.url().should('include', '/crosssearch') cy.contains('Locality name').should('be.visible') cy.contains('Country').should('be.visible') diff --git a/documentation/architecture.md b/documentation/architecture.md new file mode 100644 index 00000000..985374fb --- /dev/null +++ b/documentation/architecture.md @@ -0,0 +1,5 @@ +# Architecture picture, stack + +To do any updates to this picture, update documentation/images/architecture.drawio.svg in drawio/diagrams.net + +![Architecture Diagram](./images/architecture.drawio.svg) diff --git a/documentation/frontend/components/maps/LocalitiesMap.md b/documentation/frontend/components/maps/LocalitiesMap.md index 38b9f53c..21e6d0e0 100644 --- a/documentation/frontend/components/maps/LocalitiesMap.md +++ b/documentation/frontend/components/maps/LocalitiesMap.md @@ -25,8 +25,9 @@ A normal street map as well as a simplified map with only country borders visibl ## Country border view -The simplified country border map view is achieved through a hardcoded array of latitude-longitude polygons that represent country borders, imported from [`country_borders_WGS84.ts`](../../../../frontend/src/components/Map/country_borders_WGS84.ts). + +The simplified country border map view is achieved through an array of latitude-longitude polygons that represent country borders, imported from [`countryPolygons.ts`](../../../../frontend/src/country_data/countryPolygons.ts). This means that the country borders are fixed in time to the moment of when that file was last updated. -Detailed instructions on where to get the data and how to convert it to this desired format can be found at (TBD). +Detailed instructions on where to get the data and how to convert it to this desired format can be found [here](../../../../frontend/src/country_data/README.md). diff --git a/documentation/frontend/components/maps/README.md b/documentation/frontend/components/maps/README.md index 7d9c0a32..035f7917 100644 --- a/documentation/frontend/components/maps/README.md +++ b/documentation/frontend/components/maps/README.md @@ -1,6 +1,6 @@ # Map features -See component-specific docs at [CoordinateSelectionMap](./CoordinateSelectionMap.md) and [LocalityMap](./LocalityMap.md) +See component-specific docs at [CoordinateSelectionMap](./CoordinateSelectionMap.md) and [LocalitiesMap](./LocalitiesMap.md) The map features use the React Leaflet package and OpenStreetMap. diff --git a/documentation/frontend/components/maps/SingleLocalityMap.md b/documentation/frontend/components/maps/SingleLocalityMap.md new file mode 100644 index 00000000..8cb31143 --- /dev/null +++ b/documentation/frontend/components/maps/SingleLocalityMap.md @@ -0,0 +1,5 @@ +# Single locality map + +A component for visualizing the location of a single latitude and longitude coordinate, used for the locality detail page as well as in a country bounding box check warning modal to illustrate the location with the box it missed. + +The component takes in decimal-form latitude and longitude coordinates, along with an optional array of bounding boxes to display on the map. diff --git a/documentation/images/architecture.drawio.svg b/documentation/images/architecture.drawio.svg new file mode 100644 index 00000000..6533654d --- /dev/null +++ b/documentation/images/architecture.drawio.svg @@ -0,0 +1,4 @@ + + + +
Frontend
Frontend
Backend
Backend
Service
Service
Route / RESTful
Route / RES...
Redux
Redux
React.js
React.js
Text is not SVG - cannot display
\ No newline at end of file diff --git a/documentation/testing.md b/documentation/testing.md new file mode 100644 index 00000000..5eb35675 --- /dev/null +++ b/documentation/testing.md @@ -0,0 +1,62 @@ +## User Testing Instructions + +To participate in user testing, please follow the steps below: + +1. Use the latest version of the application: + [Test Application](https://nowdb-frontend-nowdatabase.ext.ocp-test-0.k8s.it.helsinki.fi/) +2. Perform the listed tasks under typical usage conditions. +3. Note any bugs, usability issues, or unexpected behavior. +4. Submit your feedback via the provided feedback form or issue tracker. + +Your feedback is essential to improving the user experience—thank you for participating! + +--- + +## User Testing Tasks + +### Cold Tests (No Instructions for Testing) + + +| Test Case ID | Description | Preconditions | Steps to Execute | Expected Result | +|--------------|-------------|---------------|------------------|------------------| +| TC01 | Find all localities for species Primus | User is not logged in | No instructions (tests intuitivity) | See a list of Primus with their localities (no map) | +| TC02 | Find all genus that includes "Alopex" | User is not logged in | No instructions | See 9 rows with "Alopex" | +| TC03 | See localities on a map filtered by country | User is not logged in | No instructions | See filtered localities on a map | +| TC04 | Take KML export of localities from map | User is not logged in | No instructions | Get KML file downloaded | +| TC05 | View base map with country boundaries | User is not logged in | No instructions | View simple colored boundary map | +| TC06 | Info for locality on map | User is not logged in | No instructions | Click marker to view info | +| TC07 | SVG export of map localities | User is not logged in | No instructions | Download SVG file (opens in browser) | + +--- + +### Instructed Tests + + +| Test Case ID | Description | Preconditions | Steps to Execute | Expected Result | +|--------------|-------------|---------------|------------------|------------------| +| TC08 | View localities for species "lynx" | User is not logged in | 1. Go to Cross-search 2. Select species: lynx | See list of lynx localities (2 pages) | +| TC09 | Full lynx localities listed | User is not logged in | Check that list matches expected | All localities shown accurately | +| TC10 | View single locality on map via alternative path | User is not logged in | Go to localities page → choose → tab → map | See map with one locality | +| TC11 | Find localities for Cormohipparion | User is not logged in | Cross-search → filter Genus: "Cormohipparion" | See 3 pages of results | + +--- + +## Additional Testing Comments (test results can be added here) + + +| Test Case ID | Pass/Fail if one fails | Comments (1st) 10.6.2025 | Comments (2nd) 10.6.2025 | +|--------------|------------------|----------------------|------------------| +| TC01 | Fail | Meni lokaliteetteihin, ei löydä, yrittää etsiä | Ei avaa Cross-search-hakua | +| TC02 | Pass | | | +| TC03 | Fail | Ei löydä karttaa heti, ei tarpeeksi näkyvästi karttapampula | Ei ollut helposti kartta löydettävissä | +| TC04 | Pass | | | +| TC05 | Pass | Etsi controlleria, löytää lopulta | | +| TC06 | Fail | not ready for testing | | +| TC07 | Pass | Yritti tehdä exportin kartalla, ei mennyt ilman apua | Helppo toiselle | +| TC08 | Pass | | | +| TC09 | — | | | +| TC10 | Fail | Ei tajua painaa search-merkkiä, kesti liian kauan | Symbolin painallus ei riittänyt | +| TC11 | Fail | Haku tyhjensi vastaukset, vaikka käyttö oikein | | + +--- + diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 6c3592da..3c638f85 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -31,7 +31,7 @@ export default [ '**/jest.config.ts', '**/coverage', 'src/components/Map/*.js', - 'src/components/Map/country_borders_WGS84.ts', + 'src/country_data/**', ], }, ...fixupConfigRules( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aa641e74..0a2ae1c6 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,10 @@ import { Container, Grid } from '@mui/material' -import { FrontPage } from './components/FrontPage' import { NavBar } from './components/NavBar' import { BrowserRouter, Route, Routes } from 'react-router-dom' import { Login } from './components/Login' import { crossSearchPage, + frontPage, localityPage, personPage, projectPage, @@ -16,36 +16,40 @@ import { } from './components/pages' import { Notification, NotificationContextProvider } from './components/Notification' import { EmailPage } from './components/EmailPage' +import { Footer } from './Footer' const App = () => { return ( - - - - - - - - - - - - - - - - - } path="/email/" /> - } path="/login" /> - } path="/" /> - Page not found.} path="*" /> - +
+ + + - - + + + + + + + + + + + + + } path="/email/" /> + } path="/login" /> + + Page not found.
} path="*" /> + +
+
+
+