Repository: Couchers-org/couchers (language: python)

Issue:
Strong Verification indicators on profile page
The backend now returns SV info:

https://github.com/Couchers-org/couchers/blob/e7a6ee7882602db6b5f150a5061eb102589f04dd/app/proto/api.proto#L274-L276

This should be displayed on the profile page. If verification is VERIFIED, it should be a green tick next to age/gender, and if MISMATCH, then red cross. Maybe a tooltip.

Also we should have a text somewhere that says "This user has verified their identity using a biometric passport." if `has_strong_verification` is true. It would also be cute to give their picture a halo or something to put them apart from other users and encourage SV.


Discussion / hints:

Fix patch:
diff --git a/app/web/components/Icons/index.ts b/app/web/components/Icons/index.ts
index 45f5992f55..79ca0ed33c 100644
--- a/app/web/components/Icons/index.ts
+++ b/app/web/components/Icons/index.ts
@@ -1,17 +1,20 @@
-export { default as CommunityIcon } from "./CommunityIcon";
 export { default as AttendeesIcon } from "@material-ui/icons/AccountCircleOutlined";
 export { default as AddIcon } from "@material-ui/icons/AddOutlined";
 export { default as BackIcon } from "@material-ui/icons/ArrowBackOutlined";
 export { default as BugIcon } from "@material-ui/icons/BugReportOutlined";
 export { default as CakeIcon } from "@material-ui/icons/CakeOutlined";
+export { default as CheckCircleIcon } from "@material-ui/icons/CheckCircle";
 export { default as CheckIcon } from "@material-ui/icons/CheckOutlined";
-export { default as CloseIcon } from "@material-ui/icons/CloseOutlined";
-export { default as CrossIcon } from "@material-ui/icons/CloseOutlined";
+export {
+  default as CloseIcon,
+  default as CrossIcon,
+} from "@material-ui/icons/CloseOutlined";
 export { default as DoneAllIcon } from "@material-ui/icons/DoneAllOutlined";
 export { default as DoneIcon } from "@material-ui/icons/DoneOutlined";
 export { default as EditIcon } from "@material-ui/icons/EditOutlined";
 export { default as EmailIcon } from "@material-ui/icons/EmailOutlined";
 export { default as PersonIcon } from "@material-ui/icons/EmojiPeopleOutlined";
+export { default as ErrorIcon } from "@material-ui/icons/Error";
 export { default as CalendarIcon } from "@material-ui/icons/EventOutlined";
 export { default as ExpandLessIcon } from "@material-ui/icons/ExpandLessOutlined";
 export { default as ExpandMoreIcon } from "@material-ui/icons/ExpandMoreOutlined";
@@ -23,15 +26,17 @@ export { default as CouchIcon } from "@material-ui/icons/HotelOutlined";
 export { default as InfoIcon } from "@material-ui/icons/InfoOutlined";
 export { default as GlobeIcon } from "@material-ui/icons/Language";
 export { default as LanguageIcon } from "@material-ui/icons/LanguageOutlined";
-export { default as LinkedInIcon } from "@material-ui/icons/LinkedIn";
 export { default as LinkIcon } from "@material-ui/icons/LinkOutlined";
+export { default as LinkedInIcon } from "@material-ui/icons/LinkedIn";
 export { default as PinIcon } from "@material-ui/icons/LocationOn";
 export { default as LocationIcon } from "@material-ui/icons/LocationOnOutlined";
 export { default as MenuIcon } from "@material-ui/icons/Menu";
 export { default as VeryDissatisfiedIcon } from "@material-ui/icons/MoodBadOutlined";
 export { default as VerySatisfiedIcon } from "@material-ui/icons/MoodOutlined";
-export { default as QuestionIcon } from "@material-ui/icons/MoreHorizOutlined";
-export { default as MoreIcon } from "@material-ui/icons/MoreHorizOutlined";
+export {
+  default as MoreIcon,
+  default as QuestionIcon,
+} from "@material-ui/icons/MoreHorizOutlined";
 export { default as OverflowMenuIcon } from "@material-ui/icons/MoreVertOutlined";
 export { default as OpenInNewIcon } from "@material-ui/icons/OpenInNew";
 export { default as PeopleIcon } from "@material-ui/icons/PeopleOutlined";
@@ -46,5 +51,7 @@ export { default as ExtremelyDissatisfiedIcon } from "@material-ui/icons/Sentime
 export { default as ExtremelySatisfiedIcon } from "@material-ui/icons/SentimentVerySatisfiedOutlined";
 export { default as SettingsIcon } from "@material-ui/icons/SettingsOutlined";
 export { default as CommunityLeadersIcon } from "@material-ui/icons/SportsSoccerOutlined";
+export { default as VerifiedUser } from "@material-ui/icons/VerifiedUser";
 export { default as MuteIcon } from "@material-ui/icons/VolumeOffOutlined";
 export { default as WorkIcon } from "@material-ui/icons/WorkOutlined";
+export { default as CommunityIcon } from "./CommunityIcon";
diff --git a/app/web/components/LabelAndText.tsx b/app/web/components/LabelAndText.tsx
index 75e0d840f9..309668ec98 100644
--- a/app/web/components/LabelAndText.tsx
+++ b/app/web/components/LabelAndText.tsx
@@ -1,7 +1,6 @@
 import { Typography } from "@material-ui/core";
 import { makeStyles } from "@material-ui/core/styles";
 import classNames from "classnames";
-import React from "react";
 
 import TextBody from "./TextBody";
 
@@ -16,12 +15,14 @@ const useStyles = makeStyles((theme) => ({
   },
   flexItem: {
     flex: "1 1 50%",
+    display: "flex",
+    alignItems: "center",
   },
 }));
 
 export interface LabelAndTextProps {
   label: string;
-  text: string;
+  text: string | React.ReactNode;
 }
 
 export default function LabelAndText({ label, text }: LabelAndTextProps) {
diff --git a/app/web/features/profile/locales/en.json b/app/web/features/profile/locales/en.json
index 26dd713932..4d8ffcc64e 100644
--- a/app/web/features/profile/locales/en.json
+++ b/app/web/features/profile/locales/en.json
@@ -28,7 +28,12 @@
         "additional_information_section": "Additional information",
         "travel_section": "Travelled to",
         "lived_section": "Lived in",
-        "map_section": "Travel map"
+        "map_section": "Travel map",
+        "strong_verification": "This user has verified their identity using a biometric passport",
+        "age_verification_verified": "This user has verified their date of birth",
+        "age_verification_mismatch": "User provided date of birth does not match verified date of birth",
+        "gender_verification_verified": "This user has verified their gender",
+        "gender_verification_mismatch": "User provided gender does not match verified gender"
     },
     "section_tabs_a11y_label": "tabs for user's details",
     "home_info_headings": {
@@ -96,8 +101,8 @@
         "paid_onsite": "Paid onsite parking",
         "paid_offsite": "Paid offsite parking"
     },
-    "gender": {"man": "Man", "woman": "Woman"},
-    "pronouns": {"man": "he / him", "woman": "she / her"},
+    "gender": { "man": "Man", "woman": "Woman" },
+    "pronouns": { "man": "he / him", "woman": "she / her" },
     "edit_profile_tab_bar_a11y_label": "tabs to edit different parts of your profile",
     "info_unanswered": "Ask me",
     "my_connections": "My connections",
diff --git a/app/web/features/profile/view/UserOverview.tsx b/app/web/features/profile/view/UserOverview.tsx
index 0009f82beb..8a29093c22 100644
--- a/app/web/features/profile/view/UserOverview.tsx
+++ b/app/web/features/profile/view/UserOverview.tsx
@@ -1,4 +1,4 @@
-import { Card, CardActions, Typography } from "@material-ui/core";
+import { Card, CardActions, Tooltip, Typography } from "@material-ui/core";
 import Avatar from "components/Avatar";
 import BarWithHelp from "components/Bar/BarWithHelp";
 import Divider from "components/Divider";
@@ -14,6 +14,7 @@ import { HostingStatus, MeetupStatus } from "proto/api_pb";
 import React from "react";
 import makeStyles from "utils/makeStyles";
 
+import { VerifiedUser } from "@material-ui/icons";
 import { useProfileUser } from "../hooks/useProfileUser";
 import { Badges } from "./Badges";
 import { ReferencesLastActiveLabels, ResponseRateLabel } from "./userLabels";
@@ -66,6 +67,11 @@ const useStyles = makeStyles((theme) => ({
     maxWidth: "75%",
     margin: "0 auto",
   },
+  strongVerificationBadge: {
+    display: "flex",
+    alignItems: "center",
+    marginLeft: theme.spacing(0.5),
+  },
 }));
 
 type UserOverviewProps = {
@@ -92,6 +98,17 @@ export default function UserOverview({
       <div className={classes.wrapper}>
         <Typography variant="h1" className={classes.intro}>
           {user.name}
+          {user.hasStrongVerification ? (
+            <span className={classes.strongVerificationBadge}>
+              <Tooltip title={t("profile:heading.strong_verification")}>
+                <VerifiedUser
+                  data-testid="strong-verification-id"
+                  fontSize="inherit"
+                  color="primary"
+                />
+              </Tooltip>
+            </span>
+          ) : null}
         </Typography>
         <Typography variant="body1" className={classes.intro}>
           {user.city}
diff --git a/app/web/features/profile/view/userLabels.tsx b/app/web/features/profile/view/userLabels.tsx
index ff1e07b973..739a1756fa 100644
--- a/app/web/features/profile/view/userLabels.tsx
+++ b/app/web/features/profile/view/userLabels.tsx
@@ -1,9 +1,16 @@
+import { Tooltip } from "@material-ui/core";
+import { makeStyles } from "@material-ui/core/styles";
+import { CheckCircleIcon, ErrorIcon } from "components/Icons";
 import LabelAndText from "components/LabelAndText";
 import { useLanguages } from "features/profile/hooks/useLanguages";
 import { responseRateKey } from "features/queryKeys";
 import { useTranslation } from "i18n";
 import { COMMUNITIES, GLOBAL, PROFILE } from "i18n/namespaces";
-import { User } from "proto/api_pb";
+import {
+  BirthdateVerificationStatus,
+  GenderVerificationStatus,
+  User,
+} from "proto/api_pb";
 import { useQuery } from "react-query";
 import { service } from "service";
 import { dateTimeFormatter, timestamp2Date } from "utils/date";
@@ -95,6 +102,94 @@ export const ResponseRateLabel = ({ user }: Props) => {
   );
 };
 
+const useStyles = makeStyles((theme) => ({
+  iconStyles: {
+    margin: theme.spacing(0.5),
+    alignSelf: "center",
+  },
+}));
+
+const AgeAndGenderRenderer = ({ user }: Props) => {
+  const {
+    birthdateVerificationStatus,
+    genderVerificationStatus,
+    age,
+    gender,
+    pronouns,
+  } = user;
+  const { iconStyles } = useStyles();
+  const { t } = useTranslation("profile");
+
+  const getBirthdateVerificationIcon = (
+    status: BirthdateVerificationStatus
+  ) => {
+    switch (status) {
+      case BirthdateVerificationStatus.BIRTHDATE_VERIFICATION_STATUS_VERIFIED:
+        return (
+          <Tooltip title={t("heading.age_verification_verified")}>
+            <CheckCircleIcon
+              color="primary"
+              data-testid="check-circle-icon"
+              className={iconStyles}
+              fontSize="inherit"
+            />
+          </Tooltip>
+        );
+      case BirthdateVerificationStatus.BIRTHDATE_VERIFICATION_STATUS_MISMATCH:
+        return (
+          <Tooltip title={t("heading.age_verification_mismatch")}>
+            <ErrorIcon
+              color="error"
+              data-testid="error-icon"
+              className={iconStyles}
+              fontSize="inherit"
+            />
+          </Tooltip>
+        );
+      default:
+        return <>&nbsp;</>;
+    }
+  };
+
+  const getGenderVerificationIcon = (status: GenderVerificationStatus) => {
+    switch (status) {
+      case GenderVerificationStatus.GENDER_VERIFICATION_STATUS_VERIFIED:
+        return (
+          <Tooltip title={t("heading.gender_verification_verified")}>
+            <CheckCircleIcon
+              color="primary"
+              data-testid="check-circle-icon"
+              className={iconStyles}
+              fontSize="inherit"
+            />
+          </Tooltip>
+        );
+      case GenderVerificationStatus.GENDER_VERIFICATION_STATUS_MISMATCH:
+        return (
+          <Tooltip title={t("heading.gender_verification_mismatch")}>
+            <ErrorIcon
+              color="error"
+              data-testid="error-icon"
+              className={iconStyles}
+              fontSize="inherit"
+            />
+          </Tooltip>
+        );
+      default:
+        return <>&nbsp;</>;
+    }
+  };
+  return (
+    <>
+      <span>{age}</span>
+      {getBirthdateVerificationIcon(birthdateVerificationStatus)} /&nbsp;
+      <span>{gender}</span>
+      {getGenderVerificationIcon(genderVerificationStatus)}
+      <span> {pronouns && ` (${pronouns})`}</span>
+    </>
+  );
+};
+
 export const AgeGenderLanguagesLabels = ({ user }: Props) => {
   const { t } = useTranslation("profile");
   const { languages } = useLanguages();
@@ -103,9 +198,7 @@ export const AgeGenderLanguagesLabels = ({ user }: Props) => {
     <>
       <LabelAndText
         label={t("heading.age_gender")}
-        text={`${user.age} / ${user.gender} ${
-          user.pronouns ? `(${user.pronouns})` : ""
-        }`}
+        text={<AgeAndGenderRenderer user={user} />}
       />
       {languages && (
         <LabelAndText


Test patch:
diff --git a/app/web/features/profile/view/About.test.tsx b/app/web/features/profile/view/About.test.tsx
index f4f5db7d95..bf8825dffe 100644
--- a/app/web/features/profile/view/About.test.tsx
+++ b/app/web/features/profile/view/About.test.tsx
@@ -20,7 +20,7 @@ beforeEach(() => {
 });
 
 function renderAbout(user?: User.AsObject) {
-  render(<About user={user || defaultUser} />, { wrapper });
+  return render(<About user={user || defaultUser} />, { wrapper });
 }
 describe("About (user)", () => {
   it("displays both visited and lived regions", async () => {
@@ -36,6 +36,57 @@ describe("About (user)", () => {
     ).toBe(1);
   });
 
+  it("should display age and gender without verification icons when unspecified ", async () => {
+    renderAbout({
+      ...defaultUser,
+      genderVerificationStatus: 0,
+      birthdateVerificationStatus: 0,
+    });
+
+    const checkIcon = screen.queryByTestId("check-circle-icon");
+    expect(checkIcon).toBeNull();
+    const errorIcon = screen.queryByTestId("error-icon");
+    expect(errorIcon).toBeNull();
+  });
+  it("should display age and gender without verification icons when unverified", async () => {
+    renderAbout({
+      ...defaultUser,
+      genderVerificationStatus: 1,
+      birthdateVerificationStatus: 1,
+    });
+
+    const checkIcon = screen.queryByTestId("check-circle-icon");
+    expect(checkIcon).toBeNull();
+    const errorIcon = screen.queryByTestId("error-icon");
+    expect(errorIcon).toBeNull();
+  });
+
+  it("should display verification ticks when verified", async () => {
+    renderAbout({
+      ...defaultUser,
+      genderVerificationStatus: 2,
+      birthdateVerificationStatus: 2,
+    });
+
+    const checkIcons = screen.queryAllByTestId("check-circle-icon");
+    expect(checkIcons.length).toEqual(2);
+    const errorIcon = screen.queryByTestId("error-icon");
+    expect(errorIcon).toBeNull();
+  });
+
+  it("should display error icons when mismatched", async () => {
+    renderAbout({
+      ...defaultUser,
+      genderVerificationStatus: 3,
+      birthdateVerificationStatus: 3,
+    });
+
+    const checkIcons = screen.queryByTestId("check-circle-icon");
+    expect(checkIcons).toBeNull();
+    const errorIcons = screen.queryAllByTestId("error-icon");
+    expect(errorIcons.length).toEqual(2);
+  });
+
   it("displays None when there are no regions", async () => {
     renderAbout({
       ...defaultUser,
diff --git a/app/web/features/profile/view/UserOverview.test.tsx b/app/web/features/profile/view/UserOverview.test.tsx
index 5cec41c49e..50e1cbda9d 100644
--- a/app/web/features/profile/view/UserOverview.test.tsx
+++ b/app/web/features/profile/view/UserOverview.test.tsx
@@ -108,5 +108,29 @@ describe("UserOverview", () => {
       );
       expect(screen.getByText("Edit profile")).toBeInTheDocument();
     });
+    it("should not show badge when no strong verification", () => {
+      render(
+        <ProfileUserProvider
+          user={{ ...defaultUser, hasStrongVerification: false }}
+        >
+          <UserOverview showHostAndMeetAvailability={false} />
+        </ProfileUserProvider>,
+        { wrapper }
+      );
+
+      const verificationBadge = screen.queryByTestId("error-icon");
+      expect(verificationBadge).toBeNull();
+    });
+    it("should show badge when user has strong verification", () => {
+      render(
+        <ProfileUserProvider
+          user={{ ...defaultUser, hasStrongVerification: true }}
+        >
+          <UserOverview showHostAndMeetAvailability={false} />
+        </ProfileUserProvider>,
+        { wrapper }
+      );
+      expect(screen.getByTestId("strong-verification-id")).toBeInTheDocument();
+    });
   });
 });