Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Allow desk workers to update whether they have office access #80

Merged
merged 7 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/yarn.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ jobs:
- name: tar files together before artifact creation
run: tar -cvf build.tar build/
- name: Upload build.zip artifact for release
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: build
path: build.tar
overwrite: true
- name: Set variables for tag generation
id: vars
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
Expand Down
5 changes: 5 additions & 0 deletions src/apiClient/people.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export interface PersonBase {
lastName: string;
}

/** Representation of a person including office access tag*/
export interface PersonWithOfficeAccess extends PersonBase {
hasOfficeAccess: boolean;
}

/** The representation of a person in the list endpoint*/
export interface PersonSummary extends PersonBase {
email: string;
Expand Down
4 changes: 2 additions & 2 deletions src/apiClient/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PeopleGroup, PersonBase } from "./people";
import { PeopleGroup, PersonBase, PersonWithOfficeAccess } from "./people";

export interface ListWrapper<T> {
count: number;
Expand Down Expand Up @@ -39,7 +39,7 @@ export interface OfficeHour {
endTime: string;
#s: {
id: string;
deskWorker: PersonBase;
deskWorker: PersonWithOfficeAccess;
}[];
}

Expand Down
54 changes: 47 additions & 7 deletions src/components/GroupSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,40 @@ type GroupOption = PeopleGroup & {
type Props = {
groupIds: number[];
onChange: (groups: PeopleGroup[]) => void;
editableGroups?: string[]; // if unspecified, allow to edit all groups
};

export function GroupSelect({ groupIds, onChange: onChangeProps }: Props) {
export function GroupSelect({
groupIds,
onChange: onChangeProps,
editableGroups,
}: Props) {
const { data: allGroups } = useGetGroupsQuery();
const options = allGroups?.map(makeOption);
const allOptions = allGroups?.map(makeOption);
const options =
editableGroups == null
? allOptions
: allOptions?.filter(({ groupName }) =>
editableGroups.includes(groupName),
);

const values =
options == null
allOptions == null
? null
: (groupIds
.map((groupdId) => options.find((opt) => opt.id === groupdId))
.map((groupdId) => allOptions.find((opt) => opt.id === groupdId))
.filter((opt) => opt != null) as GroupOption[]);

const onChange = useCallback(
(options: MultiValue<GroupOption>) =>
onChangeProps(options.map(parseOption)),
[onChangeProps],
(rawNewValues: MultiValue<GroupOption>) => {
const newValues = getNewValues(
values ?? [],
rawNewValues,
editableGroups,
);
return onChangeProps(newValues.map(parseOption));
},
[values, editableGroups, onChangeProps],
);

return (
Expand All @@ -53,3 +71,25 @@ function parseOption(o: GroupOption): PeopleGroup {
const { value, label, ...other } = o;
return other;
}

function getNewValues(
currentValues: GroupOption[],
newValues: MultiValue<GroupOption>,
editableGroups?: string[],
) {
if (!editableGroups) {
// No restriction on what group is editable, just pass the values through
return newValues;
}
const currentValuesIds = new Set(currentValues.map(({ id }) => id));
const newValuesIds = new Set(newValues.map(({ id }) => id));
const addedValues = newValues.filter(({ id }) => !currentValuesIds.has(id));
return [
...currentValues.filter(
({ groupName, id }) =>
newValuesIds.has(id) || // the group is still included in the new values, keep it
!editableGroups?.includes(groupName), // the group is not editable, keep it
),
...addedValues,
];
}
112 changes: 110 additions & 2 deletions src/pages/OfficeHours/OfficeHoursPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,33 @@ import React, { useEffect, useState } from "react";
import styled from "styled-components";

import { cancel#, # } from "apiClient/officeHours";
import { OfficeHour } from "apiClient/types";
import { OfficeHour, User } from "apiClient/types";
import { PersonLink } from "components/PersonLink";
import { useSetPageTitle } from "hooks";
import { useGetOfficeHoursQuery } from "redux/api";
import { useCurrentUser } from "redux/auth";
import {
Roles,
useCurrentUser,
useCurrentUserReload,
usePermissions,
} from "redux/auth";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faIdCard } from "@fortawesome/free-solid-svg-icons";
import { updatePersonGroups } from "apiClient/people";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { OverlayTrigger, Tooltip } from "react-bootstrap";

dayjs.extend(weekOfYears);
dayjs.extend(customParseFormat);

export function OfficeHoursPage() {
useSetPageTitle("Office Hours");
const { data: officeHours } = useGetOfficeHoursQuery();
const { user } = useCurrentUser();

if (!user) {
return null;
}

const now = dayjs();
const officeHoursByWeek = groupBy(officeHours, ({ startTime }) => {
Expand All @@ -31,6 +46,7 @@ export function OfficeHoursPage() {
<div className="row">
<div className="col-lg-8">
<h1>Upcoming office hours</h1>
<OfficeAccessBanner />

{map(officeHoursByWeek, (officeHours, yearWeekStr) => {
const weekStr = yearWeekStr.split(",")[1];
Expand Down Expand Up @@ -63,6 +79,71 @@ export function OfficeHoursPage() {
);
}

function OfficeAccessBanner() {
const { hasOfficeAccess } = usePermissions();
const { user } = useCurrentUser();
const reloadUser = useCurrentUserReload();
const { refetch: reloadOfficeHours } = useGetOfficeHoursQuery();

const reload = () => {
reloadUser();
reloadOfficeHours();
};

return hasOfficeAccess ? (
<div className="alert alert-success">
<FontAwesomeIcon icon={faIdCard} /> You have office access 🎉. If that's
not the case anymore, click{" "}
<UnstyledButton onClick={() => removeOfficeAccess(user, reload)}>
here
</UnstyledButton>
.
</div>
) : (
<div className="alert alert-warning">
<FontAwesomeIcon icon={faIdCard} /> You do <b>not</b> have office access
😢. If you actually do, click{" "}
<UnstyledButton onClick={() => addOfficeAccess(user, reload)}>
here
</UnstyledButton>
.
</div>
);
}

function IconWithTooltip({ icon, text }: { icon: IconProp; text: string }) {
return (
<OverlayTrigger placement="top" overlay={<Tooltip>{text}</Tooltip>}>
{({ ref, ...triggerHandler }) => (
<FontAwesomeIcon
icon={icon}
ref={ref}
data-toggle="tooltip"
data-placement="top"
{...triggerHandler}
/>
)}
</OverlayTrigger>
);
}

function UnstyledButton({
onClick,
children,
}: {
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
className="btn btn-link p-0 border-0 align-baseline"
onClick={onClick}
>
{children}
</button>
);
}

function OfficeHourBlock({ officeHour }: { officeHour: OfficeHour }) {
const { user } = useCurrentUser();
const [is#Processing, setIs#Processing] = useState<boolean>(false);
Expand Down Expand Up @@ -108,6 +189,11 @@ function OfficeHourBlock({ officeHour }: { officeHour: OfficeHour }) {
{#s.map((#, i) => (
<React.Fragment key={#.deskWorker.id}>
{i > 0 && ", "}
{#.deskWorker.hasOfficeAccess && (
<>
<IconWithTooltip icon={faIdCard} text="Has office access" />{" "}
</>
)}
<PersonLink id={#.deskWorker.id} key={#.deskWorker.id}>
{#.deskWorker.firstName}
</PersonLink>
Expand Down Expand Up @@ -178,3 +264,25 @@ const WeekBlock = styled.div`
justify-content: right;
}
`;

function addOfficeAccess(user: User | undefined, cb: () => void) {
if (user == null) {
return;
}
const newGroups = [...user.groups.map(({ id }) => id), Roles.OFFICE_ACCESS];
updatePersonGroups(user.id, newGroups).then(() => {
cb();
});
}

function removeOfficeAccess(user: User | undefined, cb: () => void) {
if (user == null) {
return;
}
const newGroups = user.groups
.map(({ id }) => id)
.filter((id) => id !== Roles.OFFICE_ACCESS);
updatePersonGroups(user.id, newGroups).then(() => {
cb();
});
}
16 changes: 12 additions & 4 deletions src/pages/People/PersonProfile/PeopleGroups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useState } from "react";

import { PeopleGroup, Person, updatePersonGroups } from "apiClient/people";
import { GroupSelect } from "components/GroupSelect";
import { usePermissions } from "redux/auth";
import { useCurrentUser, usePermissions } from "redux/auth";

type Props = {
person: Person;
Expand All @@ -12,7 +12,9 @@ type Props = {

export default function PeopleGroups({ person, refreshPerson }: Props) {
const [showGroupsForm, setShowGroupForms] = useState<boolean>(false);
const { user } = useCurrentUser();
const { isOfficer } = usePermissions();
const isMyProfile = person.id === user?.id;

const noGroups = isEmpty(person.groups);

Expand All @@ -24,7 +26,7 @@ export default function PeopleGroups({ person, refreshPerson }: Props) {
if (!showGroupsForm) {
return (
<div className={containerClassName}>
{isOfficer && (
{(isOfficer || isMyProfile) && (
<button
className="mt-1 badge bg-secondary"
onClick={() => setShowGroupForms((v) => !v)}
Expand All @@ -49,6 +51,7 @@ export default function PeopleGroups({ person, refreshPerson }: Props) {

return (
<PeopleGroupsForm
isOfficer={isOfficer}
person={person}
refreshPerson={refreshPerson}
closeForm={() => setShowGroupForms(false)}
Expand All @@ -60,7 +63,8 @@ function PeopleGroupsForm({
person,
refreshPerson,
closeForm,
}: Props & { closeForm: () => void }) {
isOfficer,
}: Props & { isOfficer?: boolean; closeForm: () => void }) {
const [groups, setGroups] = useState<readonly PeopleGroup[]>(person.groups);

const onSubmit = () =>
Expand All @@ -84,7 +88,11 @@ function PeopleGroupsForm({
{isEmpty(person.groups) ? "+ Add groups" : "± Edit groups"}
</button>
</div>
<GroupSelect groupIds={map(groups, "id")} onChange={setGroups} />
<GroupSelect
groupIds={map(groups, "id")}
onChange={setGroups}
editableGroups={isOfficer ? undefined : ["Office Access"]}
/>

<div className="ms-3 d-flex justify-content-end">
<button className="btn btn-primary mt-3" onClick={onSubmit}>
Expand Down
9 changes: 8 additions & 1 deletion src/redux/auth/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { useAppDispatch, useAppSelector } from "redux/hooks";
import { checkLoggedIn } from "./authSlice";

// TODO: Ideally these should be ids rather than an arbitrary numbers
enum Roles {
export enum Roles {
BOD = 1,
GEAR_MANAGER = 6,
DESK_CAPTAIN = 8,
ADMIN = 24,
APPROVER = 25,
OFFICE_ACCESS = 28,
}

export function useLoadCurrentUser() {
Expand All @@ -32,6 +33,11 @@ export function useCurrentUser() {
}));
}

export function useCurrentUserReload() {
const dispatch = useAppDispatch();
return () => dispatch(checkLoggedIn());
}

export function usePermissions() {
const user = useCurrentUser().user;
if (user == null) {
Expand All @@ -46,5 +52,6 @@ export function usePermissions() {
isApprover: user.groups.some((g) =>
[Roles.ADMIN, Roles.APPROVER].includes(g.id),
),
hasOfficeAccess: user.groups.some(({ id }) => id === Roles.OFFICE_ACCESS),
};
}
Loading