diff --git a/app/controllers/location_accounts_controller.rb b/app/controllers/location_accounts_controller.rb index f720ef5..d36fb62 100644 --- a/app/controllers/location_accounts_controller.rb +++ b/app/controllers/location_accounts_controller.rb @@ -15,5 +15,16 @@ module LocationAccountsController set_status_updated render json: LocationAccountSerializer.new(location_account) end + + delete '/v1/location-accounts/:location_account_id' do + location_account = LocationAccount.find(params[:location_account_id]) + location = location_account.location + + ensure_or_forbidden! { current_user.admin_at?(location) } + + location_account.destroy! + + success_response + end end end diff --git a/app/controllers/locations_controller.rb b/app/controllers/locations_controller.rb index 202b2ca..be0d42b 100644 --- a/app/controllers/locations_controller.rb +++ b/app/controllers/locations_controller.rb @@ -102,5 +102,59 @@ module LocationsController stats = LocationStatsOverview.new(location, params[:date]) render json: LocationStatsOverviewSerializer.new(stats) end + + post '/v1/locations/:location_id/users' do + location = Location.find_by_id_or_permalink!(params[:location_id]) + ensure_or_forbidden! { current_user.admin_at? location } + + user = User.new( + cohorts: Cohort.where(code: request_json[:cohorts]), + location_accounts: [ + LocationAccount.new(location: location, **request_json.slice(:role, :permission_level)), + ], + **request_json.slice(:first_name, :last_name, :email, :mobile_number) + ) + + if user.save + InviteWorker.perform_async(user.id) if params[:send_invite] + + set_status_created + render json: UserSerializer.new(user) + else + error_response(user) + end + end + + patch '/v1/locations/:location_id/users/:user_id' do + location = Location.find_by_id_or_permalink!(params[:location_id]) + user = location.users.find(params[:user_id]) + + ensure_or_forbidden! { current_user.admin_at? location } + + user.update_account!( + **request_json.merge(location: location.id).symbolize_keys + ) + + set_status_updated + render json: UserSerializer.new(user) + end + + delete '/v1/locations/:location_id/users/:user_id' do + location = Location.find_by_id_or_permalink!(params[:location_id]) + user = location.users.find(params[:user_id]) + + ensure_or_forbidden! { current_user.admin_at? location } + + if params[:nuke] + ActiveRecord::Base.transaction do + user.children.each(&:destroy!) + user.destroy! + end + else + user.destroy! + end + + success_response + end end end diff --git a/app/models/location.rb b/app/models/location.rb index 3f84c39..a83da20 100644 --- a/app/models/location.rb +++ b/app/models/location.rb @@ -216,6 +216,21 @@ def downcased_cohort_schema parsed.transform_keys(&:downcase).transform_values { |v| v.map(&:downcase) } end + # cohort schema for front-end usage + def full_cohort_schema + return cohort_schema if cohort_schema.blank? + parsed = cohort_schema.is_a?(String) ? JSON.parse(cohort_schema) : cohort_schema + full_cohort_schema = {} + + parsed.each do |category, names| + full_cohort_schema[category] = names.map do |name| + [Cohort.format_code(category, name), name] + end + end + + full_cohort_schema + end + def valid_cohort_category?(category) downcased_cohort_schema.key?(category.tr('#', '').downcase) end diff --git a/app/models/user.rb b/app/models/user.rb index 8b7486b..2ef6fa2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -113,7 +113,7 @@ def self.mobile_taken?(phone_number) def self.create_account!( first_name:, last_name:, location:, role:, - password: nil, title: nil, external_id: nil, + password: nil, external_id: nil, cohorts: [], email: nil, mobile_number: nil, permission_level: 'none' ) ActiveRecord::Base.transaction do @@ -122,7 +122,8 @@ def self.create_account!( last_name: last_name, email: email, mobile_number: mobile_number, - password: password + password: password, + cohorts: Cohort.where(code: cohorts) ) l = Location.find_by_id_or_permalink!(location) @@ -132,7 +133,30 @@ def self.create_account!( user_id: u.id, location: l, role: role, - title: title, + permission_level: permission_level + ) + + u + end + end + + def update_account!( + first_name:, last_name:, location:, role:, + email: nil, cohorts: [], + mobile_number: nil, permission_level: 'none' + ) + ActiveRecord::Base.transaction do + self.update!( + first_name: first_name, + last_name: last_name, + email: email, + mobile_number: mobile_number, + cohorts: Cohort.where(code: cohorts) + ) + + la = location_accounts.find_by!(location_id: location) + la.update!( + role: role, permission_level: permission_level ) end diff --git a/app/serializers/cohort_serializer.rb b/app/serializers/cohort_serializer.rb index 55c0d03..f1ecff7 100644 --- a/app/serializers/cohort_serializer.rb +++ b/app/serializers/cohort_serializer.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class CohortSerializer < ApplicationSerializer - attributes :name, :category + attributes :name, :category, :code has_one :location end diff --git a/app/serializers/location_serializer.rb b/app/serializers/location_serializer.rb index c0b3d6e..08e21be 100644 --- a/app/serializers/location_serializer.rb +++ b/app/serializers/location_serializer.rb @@ -21,4 +21,5 @@ class LocationSerializer < ApplicationSerializer attribute :updated_at attribute :registration_code attribute :employee_count + attribute :full_cohort_schema end diff --git a/client/src/api/index.ts b/client/src/api/index.ts index d382fcf..4a698c0 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -15,6 +15,7 @@ import { } from 'src/models' import { RecordResponse } from 'src/types' import { transformRecordResponse, recordStore } from './stores' +import { TeacherStaffInput } from 'src/pages/admin/TeacherStaffForm' const BASE_URL = `${env.API_URL}/v1` @@ -158,6 +159,10 @@ export async function updateLocationAccount( return entity } +export async function deleteLocationAccount(locationAccount: LocationAccount) { + await v1.delete(`/location-accounts/${locationAccount.id}`) +} + // // Users // @@ -180,6 +185,37 @@ export async function updateUser(user: User, updates: Partial): Promise { + const response = await v1.post( + `/locations/${location.id}/users?send_invite=${send_invite}`, + transformForAPI(user) + ) + + const entity = transformRecordResponse(response.data) + assertNotArray(entity) + return entity +} + +export async function updateTeacherStaff( + user: User, + location: Location, + updates: TeacherStaffInput +): Promise { + const response = await v1.patch(`/locations/${location.id}/users/${user.id}`, transformForAPI(updates)) + + const entity = transformRecordResponse(response.data) + assertNotArray(entity) + return entity +} + +export async function deleteUser(user: User, location: Location, with_children: boolean) { + await v1.delete(`/locations/${location.id}/users/${user.id}?nuke=${with_children}`) +} + export async function completeWelcomeUser(user: User): Promise { const response = await v1.put>(`/users/${user.id}/complete-welcome`) const entity = transformRecordResponse(response.data) diff --git a/client/src/config/routes.ts b/client/src/config/routes.ts index 0e1e864..2c75347 100644 --- a/client/src/config/routes.ts +++ b/client/src/config/routes.ts @@ -46,6 +46,7 @@ import WelcomeReviewPage from 'src/pages/welcome/WelcomeReviewPage' import WelcomeSurveyPage from 'src/pages/welcome/WelcomeSurveyPage' import BrevardResourcesPage from 'src/pages/resources/BrevardResourcesPage' import AdminUserPage from 'src/pages/admin/AdminUserPage' +import AdminUserEditPage from 'src/pages/admin/AdminUserEditPage' import HelpScoutPage from 'src/pages/resources/HelpScoutPage' import PositiveResourcesPage from 'src/pages/resources/PositiveResourcesPage' import AdminDashboardPage from 'src/pages/admin/AdminDashboardPage' @@ -316,11 +317,21 @@ const routeMap = { component: AdminUsersPage, beforeEnter: beforeEnter.requireSignIn, }, + adminUserAddPath: { + path: '/admin/locations/:locationId/users/new', + component: AdminUserEditPage, + beforeEnter: beforeEnter.requireSignIn, + }, adminUserPath: { path: '/admin/locations/:locationId/users/:userId', component: AdminUserPage, beforeEnter: beforeEnter.requireSignIn, }, + adminUserEditPath: { + path: '/admin/locations/:locationId/users/:userId/edit', + component: AdminUserEditPage, + beforeEnter: beforeEnter.requireSignIn, + }, adminDashboardPath: { path: '/admin/locations/:locationId/dashboard', component: AdminDashboardPage, diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 59e020a..779204c 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -13,6 +13,12 @@ "AdminUserPermissionsPage.permission_level": "Permission Level", "AdminUsersPage.location_permissions": "Permissions", "AdminUsersPage.user_more": "More", + "AdminUserPage.unlink": "Unlink", + "AdminUserPage.unlink_caution": "Are you sure to unlink this user from the location?", + "AdminUserPage.delete": "Delete", + "AdminUserPage.with_children": "Together with Children", + "AdminUserPage.delete_caution": "Are you sure to delete this user?", + "AdminUserPage.delete_with_children_caution": "Are you sure to delete this user? The children of this user will also be deleted.", "BusinessLocationForm.business_id": "Business ID (Used for Links)", "BusinessLocationForm.business_name": "Business Name", "BusinessLocationForm.create_location": "Create Your Location", @@ -47,6 +53,7 @@ "Common.somethings_wrong": "Something went wrong", "Common.spanish": "EspaƱol", "Common.submission_failed": "Submission Failed", + "Common.save": "Save", "Common.submit": "Submit", "Common.submitting": "Submitting...", "Common.success": "Success", @@ -124,6 +131,8 @@ "Forms.mobile_number": "Mobile Number", "Forms.password": "Password", "Forms.reveal_password": "Reveal Password", + "Forms.role": "Role", + "Forms.send_invite": "Send Invitation", "GreenlightStatus.absent": "Absent", "GreenlightStatus.cleared": "Cleared", "GreenlightStatus.not_submitted": "Not Submitted", @@ -408,4 +417,4 @@ "{0, plural, one {# child} other {# children}}": "{0, plural, one {# child} other {# children}}", "{0, plural, one {# location} other {# locations}}": "{0, plural, one {# location} other {# locations}}", "{0, plural, one {child} other {children}}": "{0, plural, one {child} other {children}}" -} \ No newline at end of file +} diff --git a/client/src/models/Cohort.ts b/client/src/models/Cohort.ts index 86c1897..af78cb9 100644 --- a/client/src/models/Cohort.ts +++ b/client/src/models/Cohort.ts @@ -20,6 +20,9 @@ export class Cohort extends Model { @attr({ type: STRING }) name: string = '' + @attr({ type: STRING }) + code: string = '' + @attr({ type: DATETIME }) createdAt: DateTime = DateTime.fromISO('') } diff --git a/client/src/models/Location.ts b/client/src/models/Location.ts index 58be004..feb663b 100644 --- a/client/src/models/Location.ts +++ b/client/src/models/Location.ts @@ -28,6 +28,8 @@ export enum LocationCategories { LOCATION = 'location', } +type CohortSchema = { [key: string]: Array[] } + type CategoryTranlsations = { [key in LocationCategories]: string[] } // HACK: We need a proper way to organize translations like this const LC: CategoryTranlsations = { @@ -164,6 +166,9 @@ export class Location extends Model { @attr({ type: STRING }) parentRegistrationCode: string | null = '' + @attr({ type: Object }) + fullCohortSchema: CohortSchema | null = null + registrationWithCodeURL(): string { return `glit.me/go/${this.permalink}/code/${this.registrationCode}` } @@ -171,4 +176,11 @@ export class Location extends Model { parentRegistrationWithCodeURL(): string { return `glit.me/go/${this.permalink}/code/${this.parentRegistrationCode}` } + + hasCohortSchema(): boolean { + if (!this.fullCohortSchema) return false + if (Object.keys(this.fullCohortSchema).length === 0) return false + + return true + } } diff --git a/client/src/models/LocationAccount.ts b/client/src/models/LocationAccount.ts index 4375c02..cddebc5 100644 --- a/client/src/models/LocationAccount.ts +++ b/client/src/models/LocationAccount.ts @@ -12,6 +12,9 @@ export enum PermissionLevels { export enum Roles { STUDENT = 'student', + TEACHER = 'teacher', + STAFF = 'staff', + UNKNOWN = 'unknown', } export class LocationAccount extends Model { diff --git a/client/src/pages/admin/AdminUserEditPage.tsx b/client/src/pages/admin/AdminUserEditPage.tsx new file mode 100644 index 0000000..f891c45 --- /dev/null +++ b/client/src/pages/admin/AdminUserEditPage.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useState } from 'reactn' +import { + Page, Block, Button, Navbar, f7, +} from 'framework7-react' + +import { F7Props } from 'src/types' +import { Location, LocationAccount, User } from 'src/models' +import { + getLocation, getUser, +} from 'src/api' +import SubmitHandler from 'src/helpers/SubmitHandler' +import { dynamicPaths } from 'src/config/routes' +import { assertNotUndefined, assertNotNull } from 'src/helpers/util' +import LoadingPage from 'src/pages/util/LoadingPage' +import TeacherStaffForm from './TeacherStaffForm' + +interface State { + isLoading: Boolean + user?: User | null + location?: Location | null + locationAccount?: LocationAccount | null +} + +export default function AdminUserEditPage(props: F7Props): JSX.Element { + const { locationId, userId } = props.f7route.params + assertNotUndefined(locationId) + + const [state, setState] = useState({ + isLoading: true, + }) + + useEffect(() => { + (async () => { + const location = await getLocation(locationId) + let user = null, locationAccount = null + if (userId) { + user = await getUser(userId) + locationAccount = user.accountFor(location) + } + + setState({ + user, + location, + locationAccount, + isLoading: false, + }) + })() + }, []) + + const { user, location, locationAccount, isLoading } = state + + if (isLoading) return + + assertNotUndefined(location) + assertNotNull(location) + + const handleSuccess = () => { + props.f7router.navigate(dynamicPaths.adminUsersPath({ locationId: location.id })) + } + + return ( + + + + + + + ) +} diff --git a/client/src/pages/admin/AdminUserPage.tsx b/client/src/pages/admin/AdminUserPage.tsx index ca2b0e8..f96a97c 100644 --- a/client/src/pages/admin/AdminUserPage.tsx +++ b/client/src/pages/admin/AdminUserPage.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, } from 'reactn' import { - Page, Block, Button, Navbar, f7, ListInput, List, ListItem, + Page, Block, Button, Navbar, NavRight, f7, ListInput, List, ListItem, ListButton, Link, Icon, Checkbox, } from 'framework7-react' import { Trans, t } from '@lingui/macro' @@ -11,7 +11,7 @@ import { assertNotNull, assertNotUndefined, formatPhone } from 'src/helpers/util import { Location, User } from 'src/models' import { - getLocation, getUser, store, updateLocationAccount, + getLocation, getUser, store, updateLocationAccount, deleteLocationAccount, deleteUser, } from 'src/api' import SubmitHandler from 'src/helpers/SubmitHandler' import { LocationAccount, PermissionLevels } from 'src/models/LocationAccount' @@ -23,15 +23,17 @@ interface State { location?: Location | null locationAccount?: LocationAccount | null permissionLevel: PermissionLevels + shouldDeleteChildren: boolean } -export default function AdminUserPage(props: F7Props) { +export default function AdminUserPage(props: F7Props): JSX.Element { const { locationId, userId } = props.f7route.params assertNotUndefined(locationId) assertNotUndefined(userId) const [state, setState] = useState({ permissionLevel: PermissionLevels.NONE, + shouldDeleteChildren: false, }) useEffect(() => { @@ -43,34 +45,96 @@ export default function AdminUserPage(props: F7Props) { locationAccount, location, permissionLevel: locationAccount?.permissionLevel || PermissionLevels.NONE, + shouldDeleteChildren: false, }) })() }, []) - assertNotNull(state.location) - assertNotNull(state.locationAccount) - assertNotNull(state.user) - assertNotUndefined(state.location) - assertNotUndefined(state.locationAccount) - assertNotUndefined(state.user) - const { user, locationAccount, location } = state - const handler = new SubmitHandler(f7) + // assertNotNull(state.location) + // assertNotNull(state.locationAccount) + // assertNotNull(state.user) + // assertNotUndefined(state.location) + // assertNotUndefined(state.locationAccount) + // assertNotUndefined(state.user) + const { user, locationAccount, location, shouldDeleteChildren } = state + const unlinkHandler = new SubmitHandler(f7) + const deleteHandler = new SubmitHandler(f7) + + const handleDeleteAttempt = () => { + f7.dialog.confirm( + t({ + id: shouldDeleteChildren ? 'AdminUserPage.delete_with_children_caution' : 'AdminUserPage.delete_caution', + message: shouldDeleteChildren ? + "Are you sure to delete this user? The children of this user will also be deleted.": + "Are you sure to delete this user?", + }), + t({ id: 'AdminUserPage.delete', message: 'Delete' }), + () => { + deleteHandler.submit() + }, + ) + } + + const handleUnlinkAttempt = () => { + f7.dialog.confirm( + t({ + id: 'AdminUserPage.unlink_caution', + message: "Are you sure to unlink this user from the location?" + }), + t({ id: 'AdminUserPage.unlink', message: 'Unlink' }), + () => { + unlinkHandler.submit() + }, + ) + } + + deleteHandler.onSubmit = async () => { + assertNotNull(user) + assertNotUndefined(user) + assertNotNull(location) + assertNotUndefined(location) + + await deleteUser(user, location, shouldDeleteChildren) + } + deleteHandler.onSuccess = () => { + props.f7router.navigate(dynamicPaths.adminUsersPath({ locationId })) + } + + unlinkHandler.onSubmit = async () => { + assertNotNull(locationAccount) + assertNotUndefined(locationAccount) + + await deleteLocationAccount(locationAccount) + } + unlinkHandler.onSuccess = () => { + props.f7router.navigate(dynamicPaths.adminUsersPath({ locationId })) + } let content - if (!state.user || !state.location) { + if (!user || !location || !locationAccount) { content = } else { content = ( <> - + + {(locationAccount.role === 'teacher' || + locationAccount.role === 'staff') && + ( + + + + + + )} +

- {state.user.firstName} {state.user.lastName} is a {locationAccount.role} at {location.name} + {user.firstName} {user.lastName} is a {locationAccount.role} at {location.name}

- {state.user.firstName === 'Aidan' + {user.firstName === 'Aidan' && ( <> -

{state.user.firstName} is in the following Cohorts

+

{user.firstName} is in the following Cohorts

  • Homeroom: Verdell, Lucy
  • Bus Route: 711, 811
  • @@ -79,19 +143,19 @@ export default function AdminUserPage(props: F7Props) { )} - {state.user.mobileNumber && ( + {user.mobileNumber && ( )} { - state.user.email && ( + user.email && ( ) } @@ -136,6 +200,36 @@ export default function AdminUserPage(props: F7Props) { } + + + + + Unlink + + + + + + + {(locationAccount.role === 'teacher' || + locationAccount.role === 'staff') ? ( + setState({ ...state, shouldDeleteChildren: e.target.checked })} + title={t({ id: 'AdminUserPage.with_children', message: 'Together with Children' })} + > + + + ) : ( + + Delete + + )} + + ) } diff --git a/client/src/pages/admin/AdminUserPermissionsPage.tsx b/client/src/pages/admin/AdminUserPermissionsPage.tsx index 881dbdf..cb5abea 100644 --- a/client/src/pages/admin/AdminUserPermissionsPage.tsx +++ b/client/src/pages/admin/AdminUserPermissionsPage.tsx @@ -26,7 +26,7 @@ interface State { permissionLevel: PermissionLevels } -export default function AdminUserPermissionsPage(props: F7Props) { +export default function AdminUserPermissionsPage(props: F7Props): JSX.Element { const { locationId, userId } = props.f7route.params assertNotUndefined(locationId) assertNotUndefined(userId) diff --git a/client/src/pages/admin/AdminUsersPage.tsx b/client/src/pages/admin/AdminUsersPage.tsx index 38011fb..af3053a 100644 --- a/client/src/pages/admin/AdminUsersPage.tsx +++ b/client/src/pages/admin/AdminUsersPage.tsx @@ -231,6 +231,9 @@ export default function AdminUsersPage(props: F7Props): JSX.Element { )} + + + window.location.reload()}> diff --git a/client/src/pages/admin/TeacherStaffForm.tsx b/client/src/pages/admin/TeacherStaffForm.tsx new file mode 100644 index 0000000..b96cb53 --- /dev/null +++ b/client/src/pages/admin/TeacherStaffForm.tsx @@ -0,0 +1,181 @@ +import React, { useState, useRef } from 'react' +import { t, Trans } from '@lingui/macro' +import { useFormik, FormikProvider } from 'formik' +import { + Button, f7, List, ListInput, ListItem, Toggle, +} from 'framework7-react' + +import { LocationAccount, PermissionLevels, Roles } from 'src/models/LocationAccount' +import SubmitHandler from 'src/helpers/SubmitHandler' +import FormikInput from 'src/components/FormikInput' +import { createTeacherStaff, updateTeacherStaff } from 'src/api' +import { User, Location } from 'src/models' +import * as Yup from 'yup' +import 'src/lib/yup-phone' +import { assertArray, assertNotNull } from 'src/helpers/util' + +export class TeacherStaffInput { + firstName: string = '' + lastName: string = '' + email: string = '' + mobileNumber: string = '' + cohorts: string[] = [] + + permissionLevel: PermissionLevels = PermissionLevels.NONE + role: string = Roles.TEACHER +} + +interface Props { + user?: User | null + location: Location + locationAccount?: LocationAccount | null + onSuccess: () => void +} + +export default function TeacherStaffForm({ user, location, locationAccount, onSuccess }: Props): JSX.Element { + const submissionHandler = new SubmitHandler(f7) + const initialValues: TeacherStaffInput = new TeacherStaffInput() + let initialCohorts: string[] = [] + const cohortsRef = React.createRef() + const [sendInviteEmail, setSendInviteEmail] = useState(false) + + if (user) { + initialValues.firstName = user.firstName + initialValues.lastName = user.lastName + initialValues.email = user.email || '' + initialValues.mobileNumber = user.mobileNumber || '' + initialCohorts = user.cohorts.map(cohort => cohort.code) + } + if (locationAccount) { + initialValues.permissionLevel = locationAccount.permissionLevel || PermissionLevels.NONE + initialValues.role = locationAccount.role || Roles.TEACHER + } + + submissionHandler.onSuccess = onSuccess + + const formik = useFormik({ + validationSchema: schema, + initialValues, + onSubmit: (values) => { + submissionHandler.submit(async () => { + if (!formik.dirty) return + + let cohorts + if (cohortsRef.current) { + cohorts = cohortsRef.current.f7SmartSelect.getValue() + assertArray(cohorts) + } + + if (user) { + await updateTeacherStaff(user, location, { ...values, cohorts: cohorts || []}) + } else { + await createTeacherStaff(location, { ...values, cohorts: cohorts || [] }, sendInviteEmail) + } + }) + }, + }) + + return ( + + { + e.preventDefault() + formik.submitForm() + }} + > + + + + + + {location.hasCohortSchema() && ( + + + + )} + + + + + + + + + + + {!user && ( + + Send Invitation + setSendInviteEmail(!sendInviteEmail)} /> + + )} + + + + + ) +} + +const schema = Yup.object().shape({ + firstName: Yup.string().required(t({ id: 'Form.error_blank', message: "Can't be blank" })), + lastName: Yup.string().required(t({ id: 'Form.error_blank', message: "Can't be blank" })), + email: Yup.string() + .email(t({ id: 'Form.error_invalid', message: 'Is invalid' })) + .required(t({ id: 'Form.error_blank', message: "Can't be blank" })), + mobileNumber: Yup.string() + .phone('US', t({ id: 'Form.error_invalid', message: 'Is invalid' })) + .required(t({ id: 'Form.error_blank', message: "Can't be blank" })), +})