diff --git a/README.md b/README.md index 2e63484..e44d4a6 100644 --- a/README.md +++ b/README.md @@ -103,11 +103,12 @@ During peak times, manually searching for a free room on Google Calendar is frus 3. Set up your environment. [Calender Quickstart](https://developers.google.com/calendar/api/quickstart/js#set_up_your_environment) 4. Add the following scopes to the new credential (OAuth web client id): `./auth/userinfo.profile` and `./auth/calendar` 5. Enable the [Admin SDK API](https://console.cloud.google.com/apis/api/admin.googleapis.com/overview). This is required to read the directory resources; refer to `getCalendarResources()` -6. Copy the **Client ID**, **Client secret** from the OAuth 2.0 Client ID you created in step 1, and place it in the `.env` file. -7. Add the **Authorized javascript origins** and the **Authorized redirect URIs** in your google cloud project. -8. Check out the [installation](CONTRIBUTING.md/#installation-with-docker-1) section, to prepare the app for launching. -9. Deploy your branch and your ready to start booking! 🎉 -10. Make sure to sync latest changes from the upstream repository. +6. Enable the [People API](https://console.cloud.google.com/apis/api/people.googleapis.com). This is requred to obtain the emails of the people in your organization, used when searching attendees. +7. Copy the **Client ID**, **Client secret** from the OAuth 2.0 Client ID you created in step 1, and place it in the `.env` file. +8. Add the **Authorized javascript origins** and the **Authorized redirect URIs** in your google cloud project. +9. Check out the [installation](CONTRIBUTING.md/#installation-with-docker-1) section, to prepare the app for launching. +10. Deploy your branch and your ready to start booking! 🎉 +11. Make sure to sync latest changes from the upstream repository. **Note**: The **Authorized javascript origins** should have: ```bash diff --git a/client/generate-manifest.js b/client/generate-manifest.js index 2d8e4a9..426ee1c 100644 --- a/client/generate-manifest.js +++ b/client/generate-manifest.js @@ -23,7 +23,7 @@ const manifest = { icons: { 16: 'favicon-16x16.png', 32: 'favicon-32x32.png', - 128: 'logo128.png', + 128: 'logo_128.png', }, permissions: ['identity', 'storage'], }; diff --git a/client/public/manifest.json b/client/public/manifest.json index ab7348b..a1c3909 100644 --- a/client/public/manifest.json +++ b/client/public/manifest.json @@ -1,14 +1,14 @@ { - "name": "QuickMeet", + "name": "Quick Meet", "version": "1.0.2", - "description": "Book meeting rooms with a single click", + "description": "Book rooms with a single click", "manifest_version": 3, "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAstDgQFGfJrfxo3id/1KTHVZpKbsCRMKZJXDUsDT8JRfasB/CeDGmuVs1hFYBJcGgn9PbK/mnE9hzWERVFpa4sfGZ3o0lvyfPLUfJd7PmfZ/4PQvE4+GfVQPAz/p4OVtP1WtbN4DED3jmrXiSrZ72paioz/ydVOSRDfo8m3+s9K92LcraYXHItvs+rSKXgfKxflfGzByme/fVO2V4yvE6T0YOqdLDc2USF4KGx0llHvqnmtB2K+rLr3V1/UcM1b4fP6kCiZAo7K2Tngpqa8DxgLVp8GYZ7NPPFJqu4tG1G1iRjtwk8Qblqmw+jmH+qZ2WguGtFpxU7P2JD8znPu//OwIDAQAB", "author": "Ali Ahnaf", "action": { "default_popup": "index.html", "default_icon": "favicon-32x32.png", - "default_title": "QuickMeet" + "default_title": "Quick Meet" }, "host_permissions": [ "http://localhost:3000/*" @@ -16,7 +16,7 @@ "icons": { "16": "favicon-16x16.png", "32": "favicon-32x32.png", - "128": "logo128.png" + "128": "logo_128.png" }, "permissions": [ "identity", diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 0a1ca64..7a0d0da 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -198,6 +198,19 @@ export default class Api { } } + async searchPeople(email: string): Promise> { + try { + const res = await this.client.get('/api/directory/people', { + params: { + email, + }, + }); + + return res.data as ApiResponse; + } catch (error: any) { + return this.handleError(error); + } + } async getFloors() { try { const res = await this.client.get('/api/floors'); diff --git a/client/src/components/AttendeeInput.tsx b/client/src/components/AttendeeInput.tsx new file mode 100644 index 0000000..e6aafb0 --- /dev/null +++ b/client/src/components/AttendeeInput.tsx @@ -0,0 +1,207 @@ +import { TextField, Chip, Box, Autocomplete, debounce, IconButton, styled, Popper } from '@mui/material'; +import PeopleAltRoundedIcon from '@mui/icons-material/PeopleAltRounded'; +import { useState } from 'react'; +import { useApi } from '@/context/ApiContext'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import { isEmailValid } from '@/helpers/utility'; +import toast from 'react-hot-toast'; + +interface AttendeeInputProps { + id: string; + value?: any[]; + disabled?: boolean; + onChange: (id: string, value: string[]) => void; + type?: string; +} + +const StyledPopper = styled(Popper)(({ theme }) => ({ + '& .MuiAutocomplete-paper': { + backgroundColor: 'rgba(245, 245, 245)', + color: theme.palette.text.primary, + boxShadow: '0px 4px 10px rgba(0, 0, 0, 0.1)', + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(0.5), + }, + '& .MuiAutocomplete-listbox': { + padding: 0, + display: 'flex', + flexDirection: 'column', + }, + '& .MuiAutocomplete-option': { + padding: theme.spacing(1.5), + borderRadius: theme.shape.borderRadius, + }, +})); + +export default function AttendeeInput({ id, onChange, value, type }: AttendeeInputProps) { + const [options, setOptions] = useState([]); + const [textInput, setTextInput] = useState(''); + + const api = useApi(); + + const handleInputChange = async (_: React.SyntheticEvent, newInputValue: string) => { + if (newInputValue.length > 2) { + const res = await api.searchPeople(newInputValue); + if (res.status === 'success') { + setOptions(res.data || []); + } + } + }; + + const handleSelectionChange = (_: React.SyntheticEvent, newValue: string[]) => { + if (newValue.length > 0) { + const lastValue = newValue[newValue.length - 1].trim(); + if (isEmailValid(lastValue)) { + onChange(id, newValue); + setTextInput(''); + } else { + toast.error('Invalid email entered'); + } + } else { + onChange(id, newValue); + setTextInput(''); + } + }; + + const handleKeyDown = (event: any) => { + if (event.key === ' ') { + event.preventDefault(); + const inputValue = event.target.value.trim(); + const existingEmails = value || []; + + if (existingEmails.find((email) => email === inputValue)) { + toast.error('Duplicate email entered'); + return; + } + + if (!isEmailValid(inputValue)) { + toast.error('Invalid email entered'); + return; + } + + onChange(id, [...existingEmails, inputValue]); + setTextInput(''); + } + }; + + const onRemoveAllClick = () => { + onChange(id, []); + }; + + const debouncedInputChange = debounce(handleInputChange, 300); + + return ( + ({ + gap: '8px', + padding: '10px', + borderRadius: 1, + backgroundColor: theme.palette.common.white, + '&:focus-within': { + border: 'none', + }, + maxHeight: '65px', + overflowY: 'auto', + }), + ]} + > + + ({ + color: theme.palette.grey[50], + position: 'sticky', + top: 0, + bottom: 0, + }), + ]} + /> + option} + freeSolo + inputValue={textInput} + fullWidth + onChange={handleSelectionChange} + onInputChange={debouncedInputChange} + renderTags={(value: readonly string[], getTagProps) => + value.map((option: string, index: number) => { + const { key, ...tagProps } = getTagProps({ index }); + return ; + }) + } + renderInput={(params) => ( + setTextInput(e.target.value)} + type={type} + variant="standard" + placeholder="Attendees" + onKeyDown={handleKeyDown} + slotProps={{ + input: { + ...params.InputProps, + endAdornment: null, + }, + }} + sx={[ + (theme) => ({ + flex: 1, + py: 0, + px: 1, + '& .MuiInputBase-input': { + fontSize: theme.typography.subtitle1, + }, + '& .MuiInputBase-input::placeholder': { + color: theme.palette.primary.main, + fontSize: theme.typography.subtitle1, + }, + '& .MuiInput-underline:before, & .MuiInput-underline:hover:before': { + borderBottom: 'none !important', + }, + '& .MuiInput-underline:after': { + borderBottom: 'none', + }, + }), + ]} + /> + )} + PopperComponent={StyledPopper} + /> + 0 ? 'flex' : 'none', + }} + onClick={onRemoveAllClick} + > + ({ + color: theme.palette.grey[50], + }), + ]} + /> + + + + ); +} diff --git a/client/src/components/ChipInput.tsx b/client/src/components/ChipInput.tsx deleted file mode 100644 index bca3bef..0000000 --- a/client/src/components/ChipInput.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState } from 'react'; -import { TextField, Chip, Box } from '@mui/material'; -import { validateEmail } from '@helpers/utility'; -import { toast } from 'react-hot-toast'; -import PeopleAltRoundedIcon from '@mui/icons-material/PeopleAltRounded'; - -interface ChipInputProps { - id: string; - sx?: any; - value?: any[]; - disabled?: boolean; - onChange: (id: string, value: string[]) => void; - type?: string; -} - -export default function ChipInput({ id, sx, onChange, value, type }: ChipInputProps) { - const [inputValue, setInputValue] = useState(''); - const [chips, setChips] = useState(value || []); - - const handleKeyDown = (event: any) => { - if (event.key === 'Backspace' && inputValue === '' && chips.length > 0) { - const newChips = chips.slice(0, -1); - setChips(newChips); - onChange(id, newChips); - } else if ((event.key === 'Enter' || event.key === ' ') && inputValue.trim() !== '') { - if (validateEmail(inputValue.trim())) { - const newChips = [...chips, inputValue.trim()]; - setChips(newChips); - setInputValue(''); - - onChange(id, newChips); - } else { - toast.error('Invalid email address'); - } - } - }; - - const handleDelete = (chipToDelete: any[]) => { - const newChips = chips.filter((chip) => chip !== chipToDelete); - setChips(newChips); - onChange(id, newChips); - }; - - return ( - ({ - gap: '8px', - padding: '10px', - borderRadius: 1, - backgroundColor: theme.palette.common.white, - '&:focus-within': { - border: 'none', - }, - maxHeight: '50px', - overflowY: 'auto', - ...sx, - }), - ]} - > - ({ - color: theme.palette.grey[50], - }), - ]} - /> - {chips.map((chip, index) => ( - handleDelete(chip)} - sx={[ - (theme) => ({ - backgroundColor: theme.palette.grey[100], - color: theme.palette.common.black, - fontSize: '11px', - }), - ]} - /> - ))} - - setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={value && value.length > 0 ? '' : 'Invite attendees'} - slotProps={{ - input: { - disableUnderline: true, - }, - }} - sx={[ - (theme) => ({ - flex: 1, - py: 0, - px: 0.5, - '& .MuiInputBase-input': { - fontSize: theme.typography.subtitle1, - }, - '& .MuiInputBase-input::placeholder': { - color: theme.palette.primary, - fontSize: theme.typography.subtitle1, - }, - }), - ]} - /> - - ); -} diff --git a/client/src/helpers/utility.ts b/client/src/helpers/utility.ts index 2afc03f..a4042b8 100644 --- a/client/src/helpers/utility.ts +++ b/client/src/helpers/utility.ts @@ -160,7 +160,7 @@ export const formatMinsToHM = (value: number, decorator?: string) => { return result.trim(); }; -export const validateEmail = (email: string) => { +export const isEmailValid = (email: string) => { return String(email) .toLowerCase() .match( diff --git a/client/src/pages/Home/BookRoomView/index.tsx b/client/src/pages/Home/BookRoomView/index.tsx index acef0f2..c22cd45 100644 --- a/client/src/pages/Home/BookRoomView/index.tsx +++ b/client/src/pages/Home/BookRoomView/index.tsx @@ -23,9 +23,9 @@ import HourglassBottomRoundedIcon from '@mui/icons-material/HourglassBottomRound import RoomsDropdown, { RoomsDropdownOption } from '@components/RoomsDropdown'; import { usePreferences } from '@/context/PreferencesContext'; import StyledTextField from '@/components/StyledTextField'; -import ChipInput from '@/components/ChipInput'; import TitleIcon from '@mui/icons-material/Title'; import { useApi } from '@/context/ApiContext'; +import AttendeeInput from '@/components/AttendeeInput'; const createRoomDropdownOptions = (rooms: IConferenceRoom[]) => { return (rooms || []).map((room) => ({ value: room.email, text: room.name, seats: room.seats, floor: room.floor }) as RoomsDropdownOption); @@ -298,7 +298,7 @@ export default function BookRoomView({ onRoomBooked }: BookRoomViewProps) { }} > - + { return (rooms || []).map((room) => ({ value: room.email, text: room.name, seats: room.seats, floor: room.floor }) as RoomsDropdownOption); @@ -105,8 +105,6 @@ export default function EditEventsView({ open, event, handleClose, currentRoom, }, [formData.startTime, formData.duration, formData.seats, roomCapacityOptions]); const handleInputChange = (id: string, value: string | number | string[] | boolean) => { - console.log(formData); - setFormData((prevData) => ({ ...prevData, [id]: value, @@ -359,8 +357,7 @@ export default function EditEventsView({ open, event, handleClose, currentRoom, /> } /> - - + > { + const emails = await this.calenderService.searchPeople(client, email); + + return createResponse(emails); + } + @UseGuards(AuthGuard) @UseInterceptors(OauthInterceptor) @Put('/event') diff --git a/server/src/calender/calender.service.ts b/server/src/calender/calender.service.ts index 3a28173..bdfed9b 100644 --- a/server/src/calender/calender.service.ts +++ b/server/src/calender/calender.service.ts @@ -416,4 +416,19 @@ export class CalenderService { const floors = await this.authService.getFloors(client, domain); return floors; } + + async searchPeople(client: OAuth2Client, emailQuery: string): Promise { + const people = await this.googleApiService.searchPeople(client, emailQuery); + const emails = []; + for (const p of people) { + for (const email of p.emailAddresses) { + if (email.metadata.primary && email.metadata.verified) { + emails.push(email.value); + break; + } + } + } + + return emails; + } } diff --git a/server/src/google-api/google-api.module.ts b/server/src/google-api/google-api.module.ts index 0739c91..b7e92cb 100644 --- a/server/src/google-api/google-api.module.ts +++ b/server/src/google-api/google-api.module.ts @@ -1,4 +1,4 @@ -import { Module, Scope } from '@nestjs/common'; +import { Module, Scope, Logger } from '@nestjs/common'; import { ConfigType } from '@nestjs/config'; import { REQUEST } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; @@ -13,9 +13,9 @@ import { GoogleApiService } from './google-api.service'; inject: [REQUEST, JwtService, appConfig.KEY], scope: Scope.REQUEST, // service provider - useFactory: (request: Request, jwtService: JwtService, config: ConfigType) => { + useFactory: (request: Request, jwtService: JwtService, config: ConfigType, logger: Logger) => { const useMock = request.headers['x-mock-api'] === 'true'; - return useMock ? new GoogleApiMockService(jwtService, config) : new GoogleApiService(config); + return useMock ? new GoogleApiMockService(jwtService, config) : new GoogleApiService(config, logger); }, }, JwtService, diff --git a/server/src/google-api/google-api.service.ts b/server/src/google-api/google-api.service.ts index a57e137..ae16199 100644 --- a/server/src/google-api/google-api.service.ts +++ b/server/src/google-api/google-api.service.ts @@ -1,5 +1,5 @@ -import { ConflictException, ForbiddenException, HttpStatus, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { admin_directory_v1, calendar_v3, google } from 'googleapis'; +import { ConflictException, ForbiddenException, HttpStatus, Inject, Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { admin_directory_v1, calendar_v3, google, people_v1 } from 'googleapis'; import { IGoogleApiService } from './interfaces/google-api.interface'; import { OAuthTokenResponse } from '../auth/dto'; import { OAuth2Client } from 'google-auth-library'; @@ -11,7 +11,10 @@ import { GoogleAPIErrorMapper } from 'src/helpers/google-api-error.mapper'; @Injectable() export class GoogleApiService implements IGoogleApiService { - constructor(@Inject(appConfig.KEY) private config: ConfigType) {} + constructor( + @Inject(appConfig.KEY) private config: ConfigType, + private logger: Logger, + ) {} getOAuthClient(): OAuth2Client { return new google.auth.OAuth2(this.config.oAuthClientId, this.config.oAuthClientSecret, this.config.oAuthRedirectUrl); @@ -23,6 +26,7 @@ export class GoogleApiService implements IGoogleApiService { 'https://www.googleapis.com/auth/calendar', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/directory.readonly', ]; const oAuthClient = this.getOAuthClient(); @@ -207,4 +211,25 @@ export class GoogleApiService implements IGoogleApiService { GoogleAPIErrorMapper.handleError(err); } } + + // https://developers.google.com/people/api/rest/v1/people/searchDirectoryPeople + async searchPeople(oauth2Client: OAuth2Client, query: string): Promise { + const peopleService = google.people({ version: 'v1', auth: oauth2Client }); + + const [err, res]: [GaxiosError, GaxiosResponse] = await to( + peopleService.people.searchDirectoryPeople({ + query, + readMask: 'emailAddresses', + pageSize: 10, + sources: ['DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE'], + }), + ); + + if (err) { + this.logger.error("Couldn't search directory people: ", err); + return []; + } + + return res.data?.people || []; + } }