Skip to content

Commit

Permalink
Feature/499 gspc login (#535)
Browse files Browse the repository at this point in the history
* update agencie stat to 250 from 560.

* wip testing optional params in login destination.

* Create placeholder gspc registration page and place it behind login. Also allow for params to be passed into the login redirect.

* fix the failing tests.

* Resolve error thrown by test.

* Code cleanup.
  • Loading branch information
john-labbate authored Apr 15, 2024
1 parent 4c28014 commit 91e8d5b
Show file tree
Hide file tree
Showing 12 changed files with 204 additions and 23 deletions.
9 changes: 9 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,14 @@
"python.testing.unittestEnabled": false,
"python.testing.pytestArgs": [
"training"
],
"cSpell.words": [
"fastapi",
"GSPC",
"Loginless",
"nanostores",
"pydantic",
"USWDS",
"Vuelidate"
]
}
81 changes: 81 additions & 0 deletions training-front-end/src/components/GspcRegistration.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<script setup>
import { ref, onErrorCaptured, onBeforeMount } from 'vue';
import USWDSAlert from './USWDSAlert.vue'
import Loginless from './LoginlessFlow.vue';
onErrorCaptured((err) => {
setError(err)
return false
})
let expirationDate = ""
onBeforeMount(async () => {
const urlParams = new URLSearchParams(window.location.search);
expirationDate = urlParams.get('expirationDate')
})
const error = ref()
function startLoading() {
error.value = undefined
}
function setError(event){
error.value = event
}
</script>

<template>
<div
class="padding-top-4 padding-bottom-4"
:class="{'bg-base-lightest': isStarted && !isSubmitted}"
>
<div
class="grid-container"
data-test="post-submit"
>
<div class="grid-row">
<div class="tablet:grid-col-12">
<USWDSAlert
v-if="error"
class="tablet:grid-col-8"
status="error"
:heading="error.name"
>
{{ error.message }}
</USWDSAlert>
<Suspense>
<template #fallback>
…Loading
</template>
<Loginless
page-id="gspc_registration"
title="gspc_registration"
:header="header"
link-destination-text="GSPC Registration"
:parameters="expirationDate"
@start-loading="startLoading"
@error="setError"
>
<template #initial-greeting>
<h2>GSPC Registration</h2>
<p>Enter your email address to login. You'll receive an email with an access link.</p>
</template>

<template #more-info>
<h2>Welcome!</h2>
<p>Before you can register for GSPC, you'll need to create and complete your profile.</p>
</template>

<h2>GSPC Placeholder</h2>
<p>logged in</p>
</Loginless>
</Suspense>
</div>
</div>
</div>
</div>
</template>
11 changes: 8 additions & 3 deletions training-front-end/src/components/LoginlessFlow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
'linkDestinationText': {
type: String,
required: true
},
'parameters': {
type: String,
required: false,
default: ""
}
})
Expand Down Expand Up @@ -85,7 +90,7 @@
unregisteredEmail.value = false
})
/* Form validation for additional information if we allow registation here */
/* Form validation for additional information if we allow registration here */
const showAdditionalFormFields = computed(() => props.allowRegistration && unregisteredEmail.value)
const validations_all_info = {
name: {
Expand Down Expand Up @@ -150,7 +155,7 @@
const apiURL = new URL(`${base_url}/api/v1/get-link`)
let res
// When user has choosen a bureau use that id instead of the agency
// When user has chosen a bureau use that id instead of the agency
let {bureau_id, ...user_data} = user_input
if (bureau_id) {
user_data.agency_id = bureau_id
Expand All @@ -161,7 +166,7 @@
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify({
user: user_data,
dest: {page_id: props.pageId, title: props.title}
dest: {page_id: props.pageId, parameters: props.parameters, title: props.title}
})
})
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion training-front-end/src/components/QuizIndex.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
}
function resetQuiz() {
/* Fired when user wants to retake quiz after unsuccesful attempt */
/* Fired when user wants to retake quiz after unsuccessful attempt */
isSubmitted.value = false
quizSubmission.value = undefined
quizResults.value = undefined
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect, afterEach, vi} from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import GspcRegistration from '../GspcRegistration.vue'


import { cleanStores } from 'nanostores'
import { profile } from '../../stores/user.js'

describe('GspcRegistration', () => {
afterEach(() => {
vi.restoreAllMocks()
cleanStores(profile)
profile.set({})
})

it('loads initial view with unknown user', async () => {
vi.spyOn(global, 'fetch').mockImplementation(() => {
return Promise.resolve({ok: false, status:404, json: () => Promise.resolve([]) })
})
const wrapper = await mount(GspcRegistration)
await flushPromises()
expect(wrapper.text()).toContain("GSPC Registration")
expect(wrapper.text()).toContain("Enter your email address to login. You'll receive an email with an access link.")
})

it('shows start registration form once user is known', async () => {
vi.spyOn(global, 'fetch').mockImplementation(() => {
return Promise.resolve({ok: false, status:404, json: () => Promise.resolve([]) })
})
profile.set({name:"John Smith", jwt:"some-token-value"})
const wrapper = await mount(GspcRegistration)
await flushPromises()
expect(wrapper.text()).toContain("GSPC Placeholder")
})
})
4 changes: 2 additions & 2 deletions training-front-end/src/components/__tests__/Loginless.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ describe('Loginless', () => {
await second_form.trigger('submit.prevent')
await flushPromises()
expect(fetchspy).nthCalledWith(2, expect.any(URL), {
body: '{"user":{"name":"Molly","email":"[email protected]","agency_id":"3"},"dest":{"page_id":"page-id","title":"Training Title"}}',
body: '{"user":{"name":"Molly","email":"[email protected]","agency_id":"3"},"dest":{"page_id":"page-id","parameters":"","title":"Training Title"}}',
method: 'POST',
headers: {'Content-Type': 'application/json'},
})
Expand Down Expand Up @@ -401,7 +401,7 @@ describe('Loginless', () => {

expect(fetchspy).toBeCalledTimes(2)
expect(fetchspy).nthCalledWith(2, expect.any(URL), {
body: '{"user":{"name":"Molly","email":"[email protected]","agency_id":"1"},"dest":{"page_id":"page-id","title":"Training Title"}}',
body: '{"user":{"name":"Molly","email":"[email protected]","agency_id":"1"},"dest":{"page_id":"page-id","parameters":"","title":"Training Title"}}',
method: 'POST',
headers: {'Content-Type': 'application/json'},
})
Expand Down
17 changes: 17 additions & 0 deletions training-front-end/src/pages/gspc_registration/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
import BaseLayout from '@layouts/BaseLayout.astro';
import HeroTraining from '@components/HeroTraining.astro';
import GspcRegistration from '@components/GspcRegistration.vue';
const pageTitle = "GSPC Registration";
---

<BaseLayout title={pageTitle} description="GSPC registration page">
<HeroTraining background_class='bg_smartpay_cool_grey'>
GSPC Registration
</HeroTraining>
<GspcRegistration
client:load
client:only >
</GspcRegistration>
</BaseLayout>
9 changes: 6 additions & 3 deletions training/api/api_v1/gspc.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from training.api.deps import gspc_invite_repository
from training.api.email import send_gspc_invite_email
from training.api.auth import RequireRole
from training.config import settings

router = APIRouter()

Expand All @@ -27,15 +28,17 @@ async def gspc_admin_invite(
repo.create(email=email, certification_expiration_date=gspcInvite.certification_expiration_date)
# If performance becomes an issue use multithreading to send the emails
try:
send_gspc_invite_email(to_email=email, link="TBD")
params = gspcInvite.certification_expiration_date.strftime('%Y-%m-%d')
link = f"{settings.BASE_URL}/gspc_registration?expirationDate={params}"
send_gspc_invite_email(to_email=email, link=link)
logging.info(f"Sent gspc invite email to {email}")
except Exception as e:
logging.error("Error sending gspc invite email", e)

# Return object with both list for succcess and failure messages
# Return object with both list for success and failure messages
return gspcInvite
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="emails or experation date"
detail="emails or expiration date"
)
15 changes: 10 additions & 5 deletions training/api/api_v1/loginless_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def page_lookup():
'training_travel_pc': {'path': '/quiz/training_travel_pc/', 'required_roles': []},
'training_purchase_pc': {'path': '/quiz/training_purchase_pc/', 'required_roles': []},
'training_fleet_pc': {'path': '/quiz/training_fleet_pc/', 'required_roles': []},
'gspc_registration': {'path': '/gspc_registration', 'required_roles': []},
}


Expand All @@ -52,24 +53,27 @@ def send_link(
This link is sent via email pointing back to the frontend section the user
made the request from (the 'dest' parameter). The token is a key to the Redis
cache. When they use the link to return, we have confidence they could access
the email and look up their idendity from the cache. In cases where we add the
user to the cache this repondes with an HTTP 201.
the email and look up their identity from the cache. In cases where we add the
user to the cache this responds with an HTTP 201.
If the `user` body paremeter is an IncompleteTempUser (only has an email)
If the `user` body parameter is an IncompleteTempUser (only has an email)
this will query the database to see if the user exist. If the user does not exist
this should return an HTTP 200 to indicate to the frontend that no user was created
and it should ask them for more information (name, agency). If the email does exist,
we send that email a link.
A TempUser (has name, agency, and email) indicates a new user. We add this information
to the cache and send a link, but not the database until they have validated the email.
Parameters needed for the generated link can be passed in through the WebDestination
object and will concatenated with the user token and added to the link.
'''
try:
required_roles = page_id_lookup[dest.page_id]['required_roles']
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unkown Page Id {dest.page_id}"
detail=f"Unknown Page Id {dest.page_id}"
)
if isinstance(user, IncompleteTempUser):
# we only got the email from the front end
Expand Down Expand Up @@ -106,7 +110,8 @@ def send_link(
detail="Server Error"
)
path = page_id_lookup[dest.page_id]['path']
url = f"{settings.BASE_URL}{path}?t={token}"
parameters = f"t={token}" if not dest.parameters else f"{dest.parameters}&t={token}"
url = f"{settings.BASE_URL}{path}?{parameters}"
try:
send_email(to_email=user.email, name=user.name, link=url, training_title=dest.title)
logging.info(f"Sent confirmation email to {user.email} for {path}")
Expand Down
3 changes: 3 additions & 0 deletions training/api/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ def send_email(to_email: EmailStr, name: str, link: str, training_title: str) ->
elif training_title and "report" in training_title.lower():
subject = "GSA SmartPay® reporting information for A/OPCs"
email_subject = "Access to GSA SmartPay training report"
elif training_title and "gspc_registration" in training_title.lower():
subject = "GSA SmartPay® GSPC Registration form"
email_subject = "Access to GSA SmartPay GSPC Registration"
else:
subject = f"GSA SmartPay® {training_title} quiz"
email_subject = f"Access GSA SmartPay {training_title} quiz"
Expand Down
1 change: 1 addition & 0 deletions training/schemas/temp_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ class WebDestination(BaseModel):
after the loginless flow completes
'''
page_id: str
parameters: str
title: str
Loading

0 comments on commit 91e8d5b

Please sign in to comment.