From 4b5d23030cfdbeaad126c30de557c1114c8ae354 Mon Sep 17 00:00:00 2001 From: Aapeli Date: Thu, 11 Jul 2024 21:12:14 -0400 Subject: [PATCH 1/3] Start on profile publicity --- app/backend/src/couchers/models.py | 16 +++ app/backend/src/couchers/server.py | 5 +- app/backend/src/couchers/servicers/account.py | 25 ++++ app/backend/src/couchers/servicers/public.py | 25 ++++ app/proto/account.proto | 17 +++ app/proto/public.proto | 128 ++++++++++++++++++ 6 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 app/backend/src/couchers/servicers/public.py create mode 100644 app/proto/public.proto diff --git a/app/backend/src/couchers/models.py b/app/backend/src/couchers/models.py index 2ce86b317d..bb46e7755a 100644 --- a/app/backend/src/couchers/models.py +++ b/app/backend/src/couchers/models.py @@ -91,6 +91,19 @@ class ParkingDetails(enum.Enum): paid_offsite = enum.auto() +class ProfilePublicitySetting(enum.Enum): + # no public info + nothing = enum.auto() + # only show on map, unclickable + map_only = enum.auto() + # name, gender, location, hosting/meetup status, badges, number of references, and signup time + limited = enum.auto() + # full about me except additional info (hide my home) + most = enum.auto() + # all but references + full = enum.auto() + + class TimezoneArea(Base): __tablename__ = "timezone_areas" id = Column(BigInteger, primary_key=True) @@ -146,6 +159,9 @@ class User(Base): joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) last_active = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) + profile_publicity = Column(Enum(ProfilePublicitySetting), nullable=False, server_default="limited") + needs_to_pick_profile_publicity = Column(Boolean, nullable=False, server_default=text("false")) + # id of the last message that they received a notification about last_notified_message_id = Column(BigInteger, nullable=False, default=0) # same as above for host requests diff --git a/app/backend/src/couchers/server.py b/app/backend/src/couchers/server.py index d069bd4a3e..b173dddb4e 100644 --- a/app/backend/src/couchers/server.py +++ b/app/backend/src/couchers/server.py @@ -17,6 +17,7 @@ from couchers.servicers.donations import Donations, Stripe from couchers.servicers.events import Events from couchers.servicers.gis import GIS +from couchers.servicers.public import Public from couchers.servicers.groups import Groups from couchers.servicers.jail import Jail from couchers.servicers.media import Media, get_media_auth_interceptor @@ -45,6 +46,7 @@ iris_pb2_grpc, jail_pb2_grpc, media_pb2_grpc, + public_pb2_grpc, notifications_pb2_grpc, pages_pb2_grpc, references_pb2_grpc, @@ -69,7 +71,6 @@ def create_main_server(port): server.add_insecure_port(f"[::]:{port}") account_pb2_grpc.add_AccountServicer_to_server(Account(), server) - iris_pb2_grpc.add_IrisServicer_to_server(Iris(), server) admin_pb2_grpc.add_AdminServicer_to_server(Admin(), server) api_pb2_grpc.add_APIServicer_to_server(API(), server) auth_pb2_grpc.add_AuthServicer_to_server(Auth(), server) @@ -82,9 +83,11 @@ def create_main_server(port): events_pb2_grpc.add_EventsServicer_to_server(Events(), server) gis_pb2_grpc.add_GISServicer_to_server(GIS(), server) groups_pb2_grpc.add_GroupsServicer_to_server(Groups(), server) + iris_pb2_grpc.add_IrisServicer_to_server(Iris(), server) jail_pb2_grpc.add_JailServicer_to_server(Jail(), server) notifications_pb2_grpc.add_NotificationsServicer_to_server(Notifications(), server) pages_pb2_grpc.add_PagesServicer_to_server(Pages(), server) + public_pb2_grpc.add_PublicServicer_to_server(Public(), server) references_pb2_grpc.add_ReferencesServicer_to_server(References(), server) reporting_pb2_grpc.add_ReportingServicer_to_server(Reporting(), server) requests_pb2_grpc.add_RequestsServicer_to_server(Requests(), server) diff --git a/app/backend/src/couchers/servicers/account.py b/app/backend/src/couchers/servicers/account.py index 5e2361d5e0..6b231a7ea8 100644 --- a/app/backend/src/couchers/servicers/account.py +++ b/app/backend/src/couchers/servicers/account.py @@ -59,6 +59,24 @@ ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO, } +profilepublicitysetting2sql = { + account_pb2.PROFILE_PUBLICITY_SETTING_UNKNOWN: None, + account_pb2.PROFILE_PUBLICITY_SETTING_NOTHING: ProfilePublicitySetting.nothing, + account_pb2.PROFILE_PUBLICITY_SETTING_MAP_ONLY: ProfilePublicitySetting.map_only, + account_pb2.PROFILE_PUBLICITY_SETTING_LIMITED: ProfilePublicitySetting.limited, + account_pb2.PROFILE_PUBLICITY_SETTING_MOST: ProfilePublicitySetting.most, + account_pb2.PROFILE_PUBLICITY_SETTING_FULL: ProfilePublicitySetting.full, +} + +profilepublicitysetting2api = { + None: account_pb2.PROFILE_PUBLICITY_SETTING_UNKNOWN, + ProfilePublicitySetting.nothing: account_pb2.PROFILE_PUBLICITY_SETTING_NOTHING, + ProfilePublicitySetting.map_only: account_pb2.PROFILE_PUBLICITY_SETTING_MAP_ONLY, + ProfilePublicitySetting.limited: account_pb2.PROFILE_PUBLICITY_SETTING_LIMITED, + ProfilePublicitySetting.most: account_pb2.PROFILE_PUBLICITY_SETTING_MOST, + ProfilePublicitySetting.full: account_pb2.PROFILE_PUBLICITY_SETTING_FULL, +} + def get_strong_verification_fields(session, db_user): out = dict( @@ -457,6 +475,13 @@ def DeleteAccount(self, request, context): return empty_pb2.Empty() + def SetProfilePublicity(self, request, context): + with session_scope() as session: + user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() + user.profile_publicity = profilepublicitysetting2sql[req.setting] + return empty_pb2.Empty() + + class Iris(iris_pb2_grpc.IrisServicer): def Webhook(self, request, context): diff --git a/app/backend/src/couchers/servicers/public.py b/app/backend/src/couchers/servicers/public.py new file mode 100644 index 0000000000..5e98a9dd99 --- /dev/null +++ b/app/backend/src/couchers/servicers/public.py @@ -0,0 +1,25 @@ +import logging + +import grpc + +from couchers import errors +from couchers.constants import GUIDELINES_VERSION, TOS_VERSION +from couchers.db import session_scope +from couchers.models import User, ProfilePublicitySetting +from couchers.sql import couchers_select as select +from couchers.utils import create_coordinate +from proto import public_pb2, public_pb2_grpc +from couchers.servicers.gis import _statement_to_geojson_response + +logger = logging.getLogger(__name__) + + +class Public(public_pb2_grpc.PublicServicer): + """ + Public (logged out) APIs for getting public info + """ + + def GetPublicUsers(self, request, context): + with session_scope() as session: + statement = select(User.username, User.geom).where(User.is_visible).where(User.geom != None).where(User.profile_publicity != ProfilePublicitySetting.nothing) + return _statement_to_geojson_response(session, statement) diff --git a/app/proto/account.proto b/app/proto/account.proto index b9bd4ba390..c53968e977 100644 --- a/app/proto/account.proto +++ b/app/proto/account.proto @@ -67,6 +67,10 @@ service Account { rpc DeleteAccount(DeleteAccountReq) returns (google.protobuf.Empty) { // Sends email with confirmation link containing token to delete account } + + rpc SetProfilePublicity(SetProfilePublicityReq) returns (google.protobuf.Empty) { + // Set what info is shown to logged-out users + } } message GetAccountInfoRes { @@ -155,3 +159,16 @@ message DeleteAccountReq { bool confirm = 1; string reason = 2; } + +enum ProfilePublicitySetting { + PROFILE_PUBLICITY_SETTING_UNKNOWN = 0; + PROFILE_PUBLICITY_SETTING_NOTHING = 1; + PROFILE_PUBLICITY_SETTING_MAP_ONLY = 2; + PROFILE_PUBLICITY_SETTING_LIMITED = 3; + PROFILE_PUBLICITY_SETTING_MOST = 4; + PROFILE_PUBLICITY_SETTING_FULL = 5; +} + +message SetProfilePublicityReq { + ProfilePublicitySetting setting = 1; +} diff --git a/app/proto/public.proto b/app/proto/public.proto new file mode 100644 index 0000000000..faf08a56a8 --- /dev/null +++ b/app/proto/public.proto @@ -0,0 +1,128 @@ +syntax = "proto3"; + +package org.couchers.public; + +import "google/api/annotations.proto"; +import "google/api/httpbody.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/wrappers.proto"; + +import "annotations.proto"; +import "api.proto"; + +service Auth { + option (auth_level) = AUTH_LEVEL_OPEN; + + rpc GetPublicUsers(google.protobuf.Empty) returns (google.api.HttpBody) { + option (google.api.http) = { + get : "/geojson/public-users" + }; + } + + rpc GetPublicUser(GetPublicUserReq) returns (GetPublicUserRes) {} +} + +message GetPublicUserReq { + // username only + string user = 1; +} + +message LimitedUser { + // int64 user_id = 1; + string username = 2; + string name = 3; + string city = 4; + string hometown = 100; + + string timezone = 122; + + double lat = 33; + double lng = 34; + double radius = 35; // meters + + uint32 num_references = 7; + string gender = 8; + string pronouns = 101; + uint32 age = 9; + google.protobuf.Timestamp joined = 11; // not exact + org.couchers.api.core.HostingStatus hosting_status = 13; + org.couchers.api.core.MeetupStatus meetup_status = 102; + + repeated string badges = 38; +} + +message GetPublicUserRes { + // int64 user_id = 1; + string username = 2; + string name = 3; + string city = 4; + string hometown = 100; + + // the user's time zonename derived from their coordinates, for example "Australia/Melbourne" + string timezone = 122; + + // doubles are enough to describe lat/lng + // in EPSG4326, lat & lon are in degress, check the EPS4326 definition for details + // returned as 0.0 (default) if these are not set, note this should only happen with incomplete profiles! + double lat = 33; + double lng = 34; + double radius = 35; // meters + + // double verification = 5; // 1.0 if phone number verified, 0.0 otherwise + // double community_standing = 6; + uint32 num_references = 7; + string gender = 8; + string pronouns = 101; + uint32 age = 9; + google.protobuf.Timestamp joined = 11; // not exact + google.protobuf.Timestamp last_active = 12; // not exact + org.couchers.api.core.HostingStatus hosting_status = 13; + org.couchers.api.core.MeetupStatus meetup_status = 102; + string occupation = 14; + string education = 103; // CommonMark without images + string about_me = 15; // CommonMark without images + string my_travels = 104; // CommonMark without images + string things_i_like = 105; // CommonMark without images + string about_place = 16; // CommonMark without images + repeated string regions_visited = 40; + repeated string regions_lived = 41; + string additional_information = 106; // CommonMark without images + + // FriendshipStatus friends = 20; + // only present if friends == FriendshipStatus.PENDING + // FriendRequest pending_friend_request = 36; + + google.protobuf.UInt32Value max_guests = 22; + google.protobuf.BoolValue last_minute = 24; + google.protobuf.BoolValue has_pets = 107; + google.protobuf.BoolValue accepts_pets = 25; + google.protobuf.StringValue pet_details = 108; + google.protobuf.BoolValue has_kids = 109; + google.protobuf.BoolValue accepts_kids = 26; + google.protobuf.StringValue kid_details = 110; + google.protobuf.BoolValue has_housemates = 111; + google.protobuf.StringValue housemate_details = 112; + google.protobuf.BoolValue wheelchair_accessible = 27; + org.couchers.api.core.SmokingLocation smoking_allowed = 28; + google.protobuf.BoolValue smokes_at_home = 113; + google.protobuf.BoolValue drinking_allowed = 114; + google.protobuf.BoolValue drinks_at_home = 115; + google.protobuf.StringValue other_host_info = 116; // CommonMark without images + org.couchers.api.core.SleepingArrangement sleeping_arrangement = 117; + google.protobuf.StringValue sleeping_details = 118; // CommonMark without images + google.protobuf.StringValue area = 30; // CommonMark without images + google.protobuf.StringValue house_rules = 31; // CommonMark without images + google.protobuf.BoolValue parking = 119; + org.couchers.api.core.ParkingDetails parking_details = 120; // CommonMark without images + google.protobuf.BoolValue camping_ok = 121; + + string avatar_url = 32; + string avatar_thumbnail_url = 44; + repeated org.couchers.api.core.LanguageAbility language_abilities = 37; + + repeated string badges = 38; + + bool has_strong_verification = 39; + org.couchers.api.core.BirthdateVerificationStatus birthdate_verification_status = 42; + org.couchers.api.core.GenderVerificationStatus gender_verification_status = 43; +} From 96d875dbe9d84258cd679e3bac84f653b5ab51c4 Mon Sep 17 00:00:00 2001 From: Aapeli Date: Sat, 13 Jul 2024 13:37:00 -0400 Subject: [PATCH 2/3] Implement some frontend stuff for profile visibility --- app/backend/src/couchers/servicers/account.py | 24 ++-- app/backend/src/tests/test_account.py | 3 + app/proto/account.proto | 22 ++-- app/proto/public.proto | 1 + app/web/components/Footer/Footer.tsx | 4 +- app/web/features/auth/FeaturePreview.tsx | 5 + app/web/features/auth/locales/en.json | 11 ++ .../auth/verification/StrongVerification.tsx | 4 +- .../auth/visibility/ProfileVisibility.tsx | 122 ++++++++++++++++++ app/web/service/account.ts | 10 ++ 10 files changed, 180 insertions(+), 26 deletions(-) create mode 100644 app/web/features/auth/visibility/ProfileVisibility.tsx diff --git a/app/backend/src/couchers/servicers/account.py b/app/backend/src/couchers/servicers/account.py index 6b231a7ea8..7b847599a7 100644 --- a/app/backend/src/couchers/servicers/account.py +++ b/app/backend/src/couchers/servicers/account.py @@ -60,21 +60,21 @@ } profilepublicitysetting2sql = { - account_pb2.PROFILE_PUBLICITY_SETTING_UNKNOWN: None, - account_pb2.PROFILE_PUBLICITY_SETTING_NOTHING: ProfilePublicitySetting.nothing, - account_pb2.PROFILE_PUBLICITY_SETTING_MAP_ONLY: ProfilePublicitySetting.map_only, - account_pb2.PROFILE_PUBLICITY_SETTING_LIMITED: ProfilePublicitySetting.limited, - account_pb2.PROFILE_PUBLICITY_SETTING_MOST: ProfilePublicitySetting.most, - account_pb2.PROFILE_PUBLICITY_SETTING_FULL: ProfilePublicitySetting.full, + account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_UNKNOWN: None, + account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_NOTHING: ProfilePublicitySetting.nothing, + account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_MAP_ONLY: ProfilePublicitySetting.map_only, + account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_LIMITED: ProfilePublicitySetting.limited, + account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_MOST: ProfilePublicitySetting.most, + account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_FULL: ProfilePublicitySetting.full, } profilepublicitysetting2api = { - None: account_pb2.PROFILE_PUBLICITY_SETTING_UNKNOWN, - ProfilePublicitySetting.nothing: account_pb2.PROFILE_PUBLICITY_SETTING_NOTHING, - ProfilePublicitySetting.map_only: account_pb2.PROFILE_PUBLICITY_SETTING_MAP_ONLY, - ProfilePublicitySetting.limited: account_pb2.PROFILE_PUBLICITY_SETTING_LIMITED, - ProfilePublicitySetting.most: account_pb2.PROFILE_PUBLICITY_SETTING_MOST, - ProfilePublicitySetting.full: account_pb2.PROFILE_PUBLICITY_SETTING_FULL, + None: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_UNKNOWN, + ProfilePublicitySetting.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_NOTHING, + ProfilePublicitySetting.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_MAP_ONLY, + ProfilePublicitySetting.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_LIMITED, + ProfilePublicitySetting.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_MOST, + ProfilePublicitySetting.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_FULL, } diff --git a/app/backend/src/tests/test_account.py b/app/backend/src/tests/test_account.py index b8d06af8a4..9a835b167c 100644 --- a/app/backend/src/tests/test_account.py +++ b/app/backend/src/tests/test_account.py @@ -719,3 +719,6 @@ def test_multiple_delete_tokens(db): with session_scope() as session: assert not session.execute(select(AccountDeletionToken)).scalar_one_or_none() + +def test_profile_public_visibility(db): + pass diff --git a/app/proto/account.proto b/app/proto/account.proto index c53968e977..2c8b7e511d 100644 --- a/app/proto/account.proto +++ b/app/proto/account.proto @@ -68,7 +68,7 @@ service Account { // Sends email with confirmation link containing token to delete account } - rpc SetProfilePublicity(SetProfilePublicityReq) returns (google.protobuf.Empty) { + rpc SetProfilePublicVisibility(SetProfilePublicVisibilityReq) returns (google.protobuf.Empty) { // Set what info is shown to logged-out users } } @@ -98,6 +98,8 @@ message GetAccountInfoRes { // whether the user has all non-security emails off bool do_not_email = 13; + + ProfilePublicVisibilitySetting profile_visibility = 14; } message ChangePasswordV2Req { @@ -160,15 +162,15 @@ message DeleteAccountReq { string reason = 2; } -enum ProfilePublicitySetting { - PROFILE_PUBLICITY_SETTING_UNKNOWN = 0; - PROFILE_PUBLICITY_SETTING_NOTHING = 1; - PROFILE_PUBLICITY_SETTING_MAP_ONLY = 2; - PROFILE_PUBLICITY_SETTING_LIMITED = 3; - PROFILE_PUBLICITY_SETTING_MOST = 4; - PROFILE_PUBLICITY_SETTING_FULL = 5; +enum ProfilePublicVisibilitySetting { + PROFILE_PUBLIC_VISIBILITY_SETTING_UNKNOWN = 0; + PROFILE_PUBLIC_VISIBILITY_SETTING_NOTHING = 1; + PROFILE_PUBLIC_VISIBILITY_SETTING_MAP_ONLY = 2; + PROFILE_PUBLIC_VISIBILITY_SETTING_LIMITED = 3; + PROFILE_PUBLIC_VISIBILITY_SETTING_MOST = 4; + PROFILE_PUBLIC_VISIBILITY_SETTING_FULL = 5; } -message SetProfilePublicityReq { - ProfilePublicitySetting setting = 1; +message SetProfilePublicVisibilityReq { + ProfilePublicVisibilitySetting setting = 1; } diff --git a/app/proto/public.proto b/app/proto/public.proto index faf08a56a8..e050633df0 100644 --- a/app/proto/public.proto +++ b/app/proto/public.proto @@ -5,6 +5,7 @@ package org.couchers.public; import "google/api/annotations.proto"; import "google/api/httpbody.proto"; import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; import "google/protobuf/wrappers.proto"; import "annotations.proto"; diff --git a/app/web/components/Footer/Footer.tsx b/app/web/components/Footer/Footer.tsx index 674be12333..515b5f4514 100644 --- a/app/web/components/Footer/Footer.tsx +++ b/app/web/components/Footer/Footer.tsx @@ -10,18 +10,18 @@ import Link from "next/link"; import { ReactNode } from "react"; import { blogRoute, + contactRoute, donationsRoute, eventsRoute, faqRoute, forumURL, foundationRoute, githubURL, + helpCenterURL, missionRoute, planRoute, - helpCenterURL, teamRoute, tosRoute, - contactRoute, volunteerRoute, } from "routes"; import makeStyles from "utils/makeStyles"; diff --git a/app/web/features/auth/FeaturePreview.tsx b/app/web/features/auth/FeaturePreview.tsx index 32c399bedb..67dad1598b 100644 --- a/app/web/features/auth/FeaturePreview.tsx +++ b/app/web/features/auth/FeaturePreview.tsx @@ -9,6 +9,7 @@ import makeStyles from "utils/makeStyles"; import ChangePhone from "./phone/ChangePhone"; import useAccountInfo from "./useAccountInfo"; import StrongVerification from "./verification/StrongVerification"; +import ProfileVisibility from "./visibility/ProfileVisibility"; const useStyles = makeStyles((theme) => ({ section: { @@ -49,6 +50,10 @@ export default function FeaturePreview() { className={classes.section} accountInfo={accountInfo!} /> + )} diff --git a/app/web/features/auth/locales/en.json b/app/web/features/auth/locales/en.json index babbfe03d9..6f8676d747 100644 --- a/app/web/features/auth/locales/en.json +++ b/app/web/features/auth/locales/en.json @@ -241,5 +241,16 @@ "button_text": "Recover your account", "success": "Your account has been recovered, you can log back in. We're excited to have you back!" } + }, + "profile_visibility": { + "title": "Profile Visibility", + "choose": "Choose what information will be visible to logged-out users", + "visiblility_options": { + "nothing": "<1>Nothing: hide me completely", + "map_only": "<1>Map only: show a map pin but not my profile", + "limited": "<1>Limited: show my name, gender, location, hosting/meetup status, badges, number of references, and how long I've been a Coucher", + "most": "<1>Most: show full \"About me\" section (including my picture) except \"Additional information\"", + "full_profile": "<1>Full profile: show my full profile (except references)" + } } } diff --git a/app/web/features/auth/verification/StrongVerification.tsx b/app/web/features/auth/verification/StrongVerification.tsx index 23700cec5d..6fcd391c6c 100644 --- a/app/web/features/auth/verification/StrongVerification.tsx +++ b/app/web/features/auth/verification/StrongVerification.tsx @@ -7,7 +7,7 @@ import { GetAccountInfoRes } from "proto/account_pb"; const STRONG_VERIFICATION_URL = process.env.NEXT_PUBLIC_CONSOLE_BASE_URL + "/strong-verification"; -type ChangePhoneProps = { +type StrongVerificationProps = { accountInfo: GetAccountInfoRes.AsObject; className?: string; }; @@ -15,7 +15,7 @@ type ChangePhoneProps = { export default function StrongVerification({ className, accountInfo, -}: ChangePhoneProps) { +}: StrongVerificationProps) { const { t } = useTranslation(AUTH); return ( diff --git a/app/web/features/auth/visibility/ProfileVisibility.tsx b/app/web/features/auth/visibility/ProfileVisibility.tsx new file mode 100644 index 0000000000..941467c321 --- /dev/null +++ b/app/web/features/auth/visibility/ProfileVisibility.tsx @@ -0,0 +1,122 @@ +import { + FormControlLabel, + Radio, + RadioGroup, + Typography, +} from "@material-ui/core"; +import Alert from "components/Alert"; +import Button from "components/Button"; +import { accountInfoQueryKey } from "features/queryKeys"; +import { Empty } from "google-protobuf/google/protobuf/empty_pb"; +import { RpcError } from "grpc-web"; +import { TFunction, Trans, useTranslation } from "i18n"; +import { AUTH, GLOBAL } from "i18n/namespaces"; +import { + GetAccountInfoRes, + ProfilePublicVisibilitySetting, +} from "proto/account_pb"; +import { Controller, useForm } from "react-hook-form"; +import { useMutation, useQueryClient } from "react-query"; +import { service } from "service"; + +type ProfileVisibilityProps = { + accountInfo: GetAccountInfoRes.AsObject; + className?: string; +}; + +export default function ProfileVisibility({ + className, + accountInfo, +}: ProfileVisibilityProps) { + const { t } = useTranslation([GLOBAL, AUTH]); + + const { handleSubmit, reset, control } = + useForm<{ choice: ProfilePublicVisibilitySetting }>(); + + const onSubmit = handleSubmit(({ choice }) => { + mutate(choice); + }); + + const queryClient = useQueryClient(); + const { error, isLoading, mutate } = useMutation< + Empty, + RpcError, + ProfilePublicVisibilitySetting + >(service.account.setProfilePublicVisibility, { + onSuccess: () => { + queryClient.invalidateQueries(accountInfoQueryKey); + reset(); + }, + }); + + const choices: [number, Parameters>[0]][] = [ + [ + ProfilePublicVisibilitySetting.PROFILE_PUBLIC_VISIBILITY_SETTING_NOTHING, + "auth:profile_visibility.visiblility_options.nothing", + ], + [ + ProfilePublicVisibilitySetting.PROFILE_PUBLIC_VISIBILITY_SETTING_MAP_ONLY, + "auth:profile_visibility.visiblility_options.map_only", + ], + [ + ProfilePublicVisibilitySetting.PROFILE_PUBLIC_VISIBILITY_SETTING_LIMITED, + "auth:profile_visibility.visiblility_options.limited", + ], + [ + ProfilePublicVisibilitySetting.PROFILE_PUBLIC_VISIBILITY_SETTING_MOST, + "auth:profile_visibility.visiblility_options.most", + ], + [ + ProfilePublicVisibilitySetting.PROFILE_PUBLIC_VISIBILITY_SETTING_FULL, + "auth:profile_visibility.visiblility_options.full_profile", + ], + ]; + + return ( +
+ {t("auth:profile_visibility.title")} + + {t("auth:profile_visibility.choose")} + + {error && {error.message}} +
+ ( + onChange(Number(event.target.value))} + > + {choices.map(([setting, translationKey]) => ( + } + label={ + }} + /> + } + /> + ))} + + )} + /> + + + +
+ ); +} diff --git a/app/web/service/account.ts b/app/web/service/account.ts index 9f39c4eda8..2db31ed346 100644 --- a/app/web/service/account.ts +++ b/app/web/service/account.ts @@ -5,6 +5,8 @@ import { ChangePhoneReq, DeleteAccountReq, FillContributorFormReq, + ProfilePublicVisibilitySetting, + SetProfilePublicVisibilityReq, VerifyPhoneReq, } from "proto/account_pb"; import { @@ -100,3 +102,11 @@ export function verifyPhone(code: string) { req.setToken(code); return client.account.verifyPhone(req); } + +export function setProfilePublicVisibility( + setting: ProfilePublicVisibilitySetting +) { + const req = new SetProfilePublicVisibilityReq(); + req.setSetting(setting); + return client.account.setProfilePublicVisibility(req); +} From 93783dc090fb6945584a4d6798f5a4e0689b8192 Mon Sep 17 00:00:00 2001 From: Aapeli Date: Sat, 13 Jul 2024 18:51:19 -0400 Subject: [PATCH 3/3] Finish up most of profile visibility --- app/backend/src/couchers/jobs/handlers.py | 11 +- ...56dd44a58_add_profile_public_visibility.py | 39 +++++ app/backend/src/couchers/models.py | 6 +- app/backend/src/couchers/server.py | 4 +- app/backend/src/couchers/servicers/account.py | 31 ++-- app/backend/src/couchers/servicers/api.py | 1 + .../src/couchers/servicers/conversations.py | 5 +- app/backend/src/couchers/servicers/events.py | 12 +- app/backend/src/couchers/servicers/public.py | 100 ++++++++++++- .../src/couchers/servicers/references.py | 8 +- app/backend/src/couchers/utils.py | 9 ++ app/backend/src/tests/test_account.py | 13 +- app/proto/account.proto | 18 +-- app/proto/public.proto | 133 ++++++------------ app/web/features/auth/locales/en.json | 6 +- .../auth/visibility/ProfileVisibility.tsx | 68 ++++----- app/web/service/account.ts | 7 +- 17 files changed, 283 insertions(+), 188 deletions(-) create mode 100644 app/backend/src/couchers/migrations/versions/8f056dd44a58_add_profile_public_visibility.py diff --git a/app/backend/src/couchers/jobs/handlers.py b/app/backend/src/couchers/jobs/handlers.py index bd702aa447..b38a0bef83 100644 --- a/app/backend/src/couchers/jobs/handlers.py +++ b/app/backend/src/couchers/jobs/handlers.py @@ -5,7 +5,6 @@ import logging from datetime import date, timedelta from math import sqrt -from types import SimpleNamespace from typing import List import requests @@ -59,7 +58,7 @@ from couchers.servicers.requests import host_request_to_pb from couchers.sql import couchers_select as select from couchers.tasks import enforce_community_memberships as tasks_enforce_community_memberships -from couchers.utils import now +from couchers.utils import make_user_context, now from proto import notification_data_pb2 from proto.internal import jobs_pb2, verification_pb2 @@ -215,7 +214,7 @@ def format_title(message, group_chat, count_unseen): author=user_model_to_pb( message.author, session, - SimpleNamespace(user_id=user.id), + make_user_context(user_id=user.id), ), message=format_title(message, group_chat, count_unseen), text=message.text, @@ -269,7 +268,7 @@ def send_request_notifications(payload): user.last_notified_request_message_id = max(user.last_notified_request_message_id, max_message_id) session.flush() - context = SimpleNamespace(user_id=user.id) + context = make_user_context(user_id=user.id) notify( user_id=user.id, topic_action="host_request:missed_messages", @@ -285,7 +284,7 @@ def send_request_notifications(payload): user.last_notified_request_message_id = max(user.last_notified_request_message_id, max_message_id) session.flush() - context = SimpleNamespace(user_id=user.id) + context = make_user_context(user_id=user.id) notify( user_id=user.id, topic_action="host_request:missed_messages", @@ -431,7 +430,7 @@ def send_reference_reminders(payload): # checked in sql assert user.is_visible if not are_blocked(session, user.id, other_user.id): - context = SimpleNamespace(user_id=user.id) + context = make_user_context(user_id=user.id) notify( user_id=user.id, topic_action="reference:reminder_surfed" if surfed else "reference:reminder_hosted", diff --git a/app/backend/src/couchers/migrations/versions/8f056dd44a58_add_profile_public_visibility.py b/app/backend/src/couchers/migrations/versions/8f056dd44a58_add_profile_public_visibility.py new file mode 100644 index 0000000000..49c71698bc --- /dev/null +++ b/app/backend/src/couchers/migrations/versions/8f056dd44a58_add_profile_public_visibility.py @@ -0,0 +1,39 @@ +"""Add profile public visibility + +Revision ID: 8f056dd44a58 +Revises: 461446320dfa +Create Date: 2024-07-13 17:08:33.761879 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8f056dd44a58" +down_revision = "461446320dfa" +branch_labels = None +depends_on = None + + +def upgrade(): + profilepublicvisibility = sa.Enum("nothing", "map_only", "limited", "most", "full", name="profilepublicvisibility") + profilepublicvisibility.create(op.get_bind(), checkfirst=True) + op.add_column( + "users", + sa.Column( + "public_visibility", + profilepublicvisibility, + server_default="limited", + nullable=False, + ), + ) + op.add_column( + "users", + sa.Column("needs_to_pick_public_visibility", sa.Boolean(), server_default=sa.text("false"), nullable=False), + ) + + +def downgrade(): + op.drop_column("users", "needs_to_pick_public_visibility") + op.drop_column("users", "public_visibility") diff --git a/app/backend/src/couchers/models.py b/app/backend/src/couchers/models.py index bb46e7755a..410fc91993 100644 --- a/app/backend/src/couchers/models.py +++ b/app/backend/src/couchers/models.py @@ -91,7 +91,7 @@ class ParkingDetails(enum.Enum): paid_offsite = enum.auto() -class ProfilePublicitySetting(enum.Enum): +class ProfilePublicVisibility(enum.Enum): # no public info nothing = enum.auto() # only show on map, unclickable @@ -159,8 +159,8 @@ class User(Base): joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) last_active = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) - profile_publicity = Column(Enum(ProfilePublicitySetting), nullable=False, server_default="limited") - needs_to_pick_profile_publicity = Column(Boolean, nullable=False, server_default=text("false")) + public_visibility = Column(Enum(ProfilePublicVisibility), nullable=False, server_default="limited") + needs_to_pick_public_visibility = Column(Boolean, nullable=False, server_default=text("false")) # id of the last message that they received a notification about last_notified_message_id = Column(BigInteger, nullable=False, default=0) diff --git a/app/backend/src/couchers/server.py b/app/backend/src/couchers/server.py index b173dddb4e..b311503ec3 100644 --- a/app/backend/src/couchers/server.py +++ b/app/backend/src/couchers/server.py @@ -17,12 +17,12 @@ from couchers.servicers.donations import Donations, Stripe from couchers.servicers.events import Events from couchers.servicers.gis import GIS -from couchers.servicers.public import Public from couchers.servicers.groups import Groups from couchers.servicers.jail import Jail from couchers.servicers.media import Media, get_media_auth_interceptor from couchers.servicers.notifications import Notifications from couchers.servicers.pages import Pages +from couchers.servicers.public import Public from couchers.servicers.references import References from couchers.servicers.reporting import Reporting from couchers.servicers.requests import Requests @@ -46,9 +46,9 @@ iris_pb2_grpc, jail_pb2_grpc, media_pb2_grpc, - public_pb2_grpc, notifications_pb2_grpc, pages_pb2_grpc, + public_pb2_grpc, references_pb2_grpc, reporting_pb2_grpc, requests_pb2_grpc, diff --git a/app/backend/src/couchers/servicers/account.py b/app/backend/src/couchers/servicers/account.py index 7b847599a7..d4a703cc1f 100644 --- a/app/backend/src/couchers/servicers/account.py +++ b/app/backend/src/couchers/servicers/account.py @@ -26,6 +26,7 @@ AccountDeletionToken, ContributeOption, ContributorForm, + ProfilePublicVisibility, StrongVerificationAttempt, StrongVerificationAttemptStatus, StrongVerificationCallbackEvent, @@ -60,21 +61,21 @@ } profilepublicitysetting2sql = { - account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_UNKNOWN: None, - account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_NOTHING: ProfilePublicitySetting.nothing, - account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_MAP_ONLY: ProfilePublicitySetting.map_only, - account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_LIMITED: ProfilePublicitySetting.limited, - account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_MOST: ProfilePublicitySetting.most, - account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_FULL: ProfilePublicitySetting.full, + account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None, + account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing, + account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only, + account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited, + account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most, + account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full, } profilepublicitysetting2api = { - None: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_UNKNOWN, - ProfilePublicitySetting.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_NOTHING, - ProfilePublicitySetting.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_MAP_ONLY, - ProfilePublicitySetting.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_LIMITED, - ProfilePublicitySetting.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_MOST, - ProfilePublicitySetting.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_SETTING_FULL, + None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN, + ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING, + ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY, + ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED, + ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST, + ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL, } @@ -140,6 +141,7 @@ def GetAccountInfo(self, request, context): phone_verified=user.phone_is_verified, profile_complete=user.has_completed_profile, timezone=user.timezone, + profile_public_visibility=profilepublicitysetting2api[user.public_visibility], **get_strong_verification_fields(session, user), ) @@ -475,14 +477,13 @@ def DeleteAccount(self, request, context): return empty_pb2.Empty() - def SetProfilePublicity(self, request, context): + def SetProfilePublicVisibility(self, request, context): with session_scope() as session: user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() - user.profile_publicity = profilepublicitysetting2sql[req.setting] + user.public_visibility = profilepublicitysetting2sql[request.profile_public_visibility] return empty_pb2.Empty() - class Iris(iris_pb2_grpc.IrisServicer): def Webhook(self, request, context): json_data = json.loads(request.data) diff --git a/app/backend/src/couchers/servicers/api.py b/app/backend/src/couchers/servicers/api.py index 1cc470b29d..facfc5fd49 100644 --- a/app/backend/src/couchers/servicers/api.py +++ b/app/backend/src/couchers/servicers/api.py @@ -733,6 +733,7 @@ def InitiateMediaUpload(self, request, context): def user_model_to_pb(db_user, session, context): + # note that this function is sometimes called by a logged out user, in which case context comes from make_logged_out_context num_references = session.execute( select(func.count()) .select_from(Reference) diff --git a/app/backend/src/couchers/servicers/conversations.py b/app/backend/src/couchers/servicers/conversations.py index dad5107e94..cce8746745 100644 --- a/app/backend/src/couchers/servicers/conversations.py +++ b/app/backend/src/couchers/servicers/conversations.py @@ -1,6 +1,5 @@ import logging from datetime import timedelta -from types import SimpleNamespace import grpc from google.protobuf import empty_pb2 @@ -15,7 +14,7 @@ from couchers.servicers.api import user_model_to_pb from couchers.servicers.blocking import are_blocked from couchers.sql import couchers_select as select -from couchers.utils import Timestamp_from_datetime, now +from couchers.utils import Timestamp_from_datetime, make_user_context, now from proto import conversations_pb2, conversations_pb2_grpc, notification_data_pb2 from proto.internal import jobs_pb2 @@ -167,7 +166,7 @@ def generate_message_notifications(payload: jobs_pb2.GenerateMessageNotification author=user_model_to_pb( message.author, session, - SimpleNamespace(user_id=subscription.user_id), + make_user_context(user_id=subscription.user_id), ), message=msg, text=message.text, diff --git a/app/backend/src/couchers/servicers/events.py b/app/backend/src/couchers/servicers/events.py index 4589b4d44b..2733c27b24 100644 --- a/app/backend/src/couchers/servicers/events.py +++ b/app/backend/src/couchers/servicers/events.py @@ -1,6 +1,5 @@ import logging from datetime import timedelta -from types import SimpleNamespace import grpc from google.protobuf import empty_pb2 @@ -35,6 +34,7 @@ Timestamp_from_datetime, create_coordinate, dt_from_millis, + make_user_context, millis_from_dt, now, to_aware_datetime, @@ -264,7 +264,7 @@ def generate_event_create_notifications(payload: jobs_pb2.GenerateEventCreateNot for user in users: if are_blocked(session, user.id, creator.id): continue - context = SimpleNamespace(user_id=user.id) + context = make_user_context(user_id=user.id) notify( user_id=user.id, topic_action="event:create_approved" if payload.approved else "event:create_any", @@ -291,7 +291,7 @@ def generate_event_update_notifications(payload: jobs_pb2.GenerateEventUpdateNot logger.info(user_id) if are_blocked(session, user_id, updating_user.id): continue - context = SimpleNamespace(user_id=user_id) + context = make_user_context(user_id=user_id) notify( user_id=user_id, topic_action="event:update", @@ -319,7 +319,7 @@ def generate_event_cancel_notifications(payload: jobs_pb2.GenerateEventCancelNot logger.info(user_id) if are_blocked(session, user_id, cancelling_user.id): continue - context = SimpleNamespace(user_id=user_id) + context = make_user_context(user_id=user_id) notify( user_id=user_id, topic_action="event:cancel", @@ -342,7 +342,7 @@ def generate_event_delete_notifications(payload: jobs_pb2.GenerateEventDeleteNot for user_id in set(subscribed_user_ids + attending_user_ids): logger.info(user_id) - context = SimpleNamespace(user_id=user_id) + context = make_user_context(user_id=user_id) notify( user_id=user_id, topic_action="event:delete", @@ -1117,7 +1117,7 @@ def InviteEventOrganizer(self, request, context): ) session.flush() - other_user_context = SimpleNamespace(user_id=request.user_id) + other_user_context = make_user_context(user_id=request.user_id) notify( user_id=request.user_id, diff --git a/app/backend/src/couchers/servicers/public.py b/app/backend/src/couchers/servicers/public.py index 5e98a9dd99..96b3fcce49 100644 --- a/app/backend/src/couchers/servicers/public.py +++ b/app/backend/src/couchers/servicers/public.py @@ -1,15 +1,16 @@ import logging import grpc +from sqlalchemy.sql import func, union_all from couchers import errors -from couchers.constants import GUIDELINES_VERSION, TOS_VERSION from couchers.db import session_scope -from couchers.models import User, ProfilePublicitySetting -from couchers.sql import couchers_select as select -from couchers.utils import create_coordinate -from proto import public_pb2, public_pb2_grpc +from couchers.models import ProfilePublicVisibility, Reference, User +from couchers.servicers.api import fluency2api, hostingstatus2api, meetupstatus2api, user_model_to_pb from couchers.servicers.gis import _statement_to_geojson_response +from couchers.sql import couchers_select as select +from couchers.utils import Timestamp_from_datetime, make_logged_out_context +from proto import api_pb2, public_pb2, public_pb2_grpc logger = logging.getLogger(__name__) @@ -21,5 +22,92 @@ class Public(public_pb2_grpc.PublicServicer): def GetPublicUsers(self, request, context): with session_scope() as session: - statement = select(User.username, User.geom).where(User.is_visible).where(User.geom != None).where(User.profile_publicity != ProfilePublicitySetting.nothing) + with_geom = ( + select(User.username, User.geom) + .where(User.is_visible) + .where(User.geom != None) + .where(User.public_visibility != ProfilePublicVisibility.nothing) + .where(User.public_visibility != ProfilePublicVisibility.map_only) + ) + without_geom = ( + select(None, User.geom) + .where(User.is_visible) + .where(User.geom != None) + .where(User.public_visibility == ProfilePublicVisibility.map_only) + ) + statement = union_all(with_geom, without_geom) return _statement_to_geojson_response(session, statement) + + def GetPublicUser(self, request, context): + with session_scope() as session: + user = session.execute( + select(User) + .where(User.is_visible) + .where(User.username == request.user) + .where( + User.public_visibility.in_( + [ProfilePublicVisibility.limited, ProfilePublicVisibility.most, ProfilePublicVisibility.full] + ) + ) + ).scalar_one_or_none() + + if not user: + context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND) + + if user.public_visibility == ProfilePublicVisibility.full: + return public_pb2.GetPublicUserRes(full_user=user_model_to_pb(user, session, make_logged_out_context())) + + num_references = session.execute( + select(func.count()) + .select_from(Reference) + .join(User, User.id == Reference.from_user_id) + .where(User.is_visible) + .where(Reference.to_user_id == user.id) + ).scalar_one() + + if user.public_visibility == ProfilePublicVisibility.limited: + return public_pb2.GetPublicUserRes( + limited_user=public_pb2.LimitedUser( + username=user.username, + name=user.name, + city=user.city, + hometown=user.hometown, + num_references=num_references, + joined=Timestamp_from_datetime(user.display_joined), + hosting_status=hostingstatus2api[user.hosting_status], + meetup_status=meetupstatus2api[user.meetup_status], + badges=[badge.badge_id for badge in user.badges], + ) + ) + + if user.public_visibility == ProfilePublicVisibility.most: + return public_pb2.GetPublicUserRes( + most_user=public_pb2.MostUser( + username=user.username, + name=user.name, + city=user.city, + hometown=user.hometown, + timezone=user.timezone, + num_references=num_references, + gender=user.gender, + pronouns=user.pronouns, + age=user.age, + joined=Timestamp_from_datetime(user.display_joined), + last_active=Timestamp_from_datetime(user.display_last_active), + hosting_status=hostingstatus2api[user.hosting_status], + meetup_status=meetupstatus2api[user.meetup_status], + occupation=user.occupation, + education=user.education, + about_me=user.about_me, + things_i_like=user.things_i_like, + language_abilities=[ + api_pb2.LanguageAbility(code=ability.language_code, fluency=fluency2api[ability.fluency]) + for ability in user.language_abilities + ], + regions_visited=[region.code for region in user.regions_visited], + regions_lived=[region.code for region in user.regions_lived], + avatar_url=user.avatar.full_url if user.avatar else None, + avatar_thumbnail_url=user.avatar.thumbnail_url if user.avatar else None, + badges=[badge.badge_id for badge in user.badges], + ) + ) diff --git a/app/backend/src/couchers/servicers/references.py b/app/backend/src/couchers/servicers/references.py index a81233c3c1..81d3bef7bc 100644 --- a/app/backend/src/couchers/servicers/references.py +++ b/app/backend/src/couchers/servicers/references.py @@ -5,8 +5,6 @@ * References become visible after min{2 weeks, both reciprocal references written} """ -from types import SimpleNamespace - import grpc from sqlalchemy.orm import aliased from sqlalchemy.sql import and_, func, literal, or_, union_all @@ -18,7 +16,7 @@ from couchers.servicers.api import user_model_to_pb from couchers.sql import couchers_select as select from couchers.tasks import maybe_send_reference_report_email -from couchers.utils import Timestamp_from_datetime +from couchers.utils import Timestamp_from_datetime, make_user_context from proto import notification_data_pb2, references_pb2, references_pb2_grpc reftype2sql = { @@ -174,7 +172,7 @@ def WriteFriendReference(self, request, context): user_id=request.to_user_id, topic_action="reference:receive_friend", data=notification_data_pb2.ReferenceReceiveFriend( - from_user=user_model_to_pb(user, session, SimpleNamespace(user_id=request.to_user_id)), + from_user=user_model_to_pb(user, session, make_user_context(user_id=request.to_user_id)), text=reference_text, ), ) @@ -250,7 +248,7 @@ def WriteHostRequestReference(self, request, context): topic_action="reference:receive_surfed" if surfed else "reference:receive_hosted", data=notification_data_pb2.ReferenceReceiveHostRequest( host_request_id=host_request.conversation_id, - from_user=user_model_to_pb(user, session, SimpleNamespace(user_id=reference.to_user_id)), + from_user=user_model_to_pb(user, session, make_user_context(user_id=reference.to_user_id)), text=reference_text if other_reference is not None else None, ), ) diff --git a/app/backend/src/couchers/utils.py b/app/backend/src/couchers/utils.py index 25a255bc7a..0cc2cd643f 100644 --- a/app/backend/src/couchers/utils.py +++ b/app/backend/src/couchers/utils.py @@ -2,6 +2,7 @@ import re from datetime import date, datetime, timedelta from email.utils import formatdate +from types import SimpleNamespace from zoneinfo import ZoneInfo import pytz @@ -284,3 +285,11 @@ def last_active_coarsen(dt): def get_tz_as_text(tz_name): return datetime.now(tz=ZoneInfo(tz_name)).strftime("%Z/UTC%z") + + +def make_user_context(user_id): + return SimpleNamespace(user_id=user_id) + + +def make_logged_out_context(): + return SimpleNamespace(user_id=0) diff --git a/app/backend/src/tests/test_account.py b/app/backend/src/tests/test_account.py index 9a835b167c..a1441b383c 100644 --- a/app/backend/src/tests/test_account.py +++ b/app/backend/src/tests/test_account.py @@ -720,5 +720,14 @@ def test_multiple_delete_tokens(db): with session_scope() as session: assert not session.execute(select(AccountDeletionToken)).scalar_one_or_none() -def test_profile_public_visibility(db): - pass + +# def test_public_visibility(db): +# user, token = generate_user() + +# with account_session(token) as account: +# account.SetProfilePublicVisibility(account_pb2.SetProfilePublicVisibilityReq(setting=account_pb2.PublicVisibility.)) +# account.DeleteAccount(account_pb2.DeleteAccountReq(confirm=True)) +# account.DeleteAccount(account_pb2.DeleteAccountReq(confirm=True)) + +# # pass +# # res = account.GetAccountInfo(empty_pb2.Empty()) diff --git a/app/proto/account.proto b/app/proto/account.proto index 2c8b7e511d..a299486400 100644 --- a/app/proto/account.proto +++ b/app/proto/account.proto @@ -99,7 +99,7 @@ message GetAccountInfoRes { // whether the user has all non-security emails off bool do_not_email = 13; - ProfilePublicVisibilitySetting profile_visibility = 14; + ProfilePublicVisibility profile_public_visibility = 14; } message ChangePasswordV2Req { @@ -162,15 +162,15 @@ message DeleteAccountReq { string reason = 2; } -enum ProfilePublicVisibilitySetting { - PROFILE_PUBLIC_VISIBILITY_SETTING_UNKNOWN = 0; - PROFILE_PUBLIC_VISIBILITY_SETTING_NOTHING = 1; - PROFILE_PUBLIC_VISIBILITY_SETTING_MAP_ONLY = 2; - PROFILE_PUBLIC_VISIBILITY_SETTING_LIMITED = 3; - PROFILE_PUBLIC_VISIBILITY_SETTING_MOST = 4; - PROFILE_PUBLIC_VISIBILITY_SETTING_FULL = 5; +enum ProfilePublicVisibility { + PROFILE_PUBLIC_VISIBILITY_UNKNOWN = 0; + PROFILE_PUBLIC_VISIBILITY_NOTHING = 1; + PROFILE_PUBLIC_VISIBILITY_MAP_ONLY = 2; + PROFILE_PUBLIC_VISIBILITY_LIMITED = 3; + PROFILE_PUBLIC_VISIBILITY_MOST = 4; + PROFILE_PUBLIC_VISIBILITY_FULL = 5; } message SetProfilePublicVisibilityReq { - ProfilePublicVisibilitySetting setting = 1; + ProfilePublicVisibility profile_public_visibility = 1; } diff --git a/app/proto/public.proto b/app/proto/public.proto index e050633df0..d92d093a28 100644 --- a/app/proto/public.proto +++ b/app/proto/public.proto @@ -6,12 +6,11 @@ import "google/api/annotations.proto"; import "google/api/httpbody.proto"; import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; -import "google/protobuf/wrappers.proto"; import "annotations.proto"; import "api.proto"; -service Auth { +service Public { option (auth_level) = AUTH_LEVEL_OPEN; rpc GetPublicUsers(google.protobuf.Empty) returns (google.api.HttpBody) { @@ -29,101 +28,53 @@ message GetPublicUserReq { } message LimitedUser { - // int64 user_id = 1; - string username = 2; - string name = 3; - string city = 4; - string hometown = 100; + string username = 1; + string name = 2; + string city = 3; + string hometown = 4; - string timezone = 122; + uint32 num_references = 5; + google.protobuf.Timestamp joined = 6; // not exact + org.couchers.api.core.HostingStatus hosting_status = 7; + org.couchers.api.core.MeetupStatus meetup_status = 8; - double lat = 33; - double lng = 34; - double radius = 35; // meters + repeated string badges = 9; +} + +message MostUser { + string username = 1; + string name = 2; + string city = 3; + string hometown = 4; - uint32 num_references = 7; - string gender = 8; - string pronouns = 101; + string timezone = 5; + + uint32 num_references = 6; + string gender = 7; + string pronouns = 8; uint32 age = 9; - google.protobuf.Timestamp joined = 11; // not exact - org.couchers.api.core.HostingStatus hosting_status = 13; - org.couchers.api.core.MeetupStatus meetup_status = 102; + google.protobuf.Timestamp joined = 10; // not exact + google.protobuf.Timestamp last_active = 11; // not exact + org.couchers.api.core.HostingStatus hosting_status = 12; + org.couchers.api.core.MeetupStatus meetup_status = 13; + string occupation = 14; + string education = 15; // CommonMark without images + string about_me = 16; // CommonMark without images + string things_i_like = 17; // CommonMark without images + repeated string regions_visited = 18; + repeated string regions_lived = 19; - repeated string badges = 38; + string avatar_url = 20; + string avatar_thumbnail_url = 21; + repeated org.couchers.api.core.LanguageAbility language_abilities = 22; + + repeated string badges = 23; } message GetPublicUserRes { - // int64 user_id = 1; - string username = 2; - string name = 3; - string city = 4; - string hometown = 100; - - // the user's time zonename derived from their coordinates, for example "Australia/Melbourne" - string timezone = 122; - - // doubles are enough to describe lat/lng - // in EPSG4326, lat & lon are in degress, check the EPS4326 definition for details - // returned as 0.0 (default) if these are not set, note this should only happen with incomplete profiles! - double lat = 33; - double lng = 34; - double radius = 35; // meters - - // double verification = 5; // 1.0 if phone number verified, 0.0 otherwise - // double community_standing = 6; - uint32 num_references = 7; - string gender = 8; - string pronouns = 101; - uint32 age = 9; - google.protobuf.Timestamp joined = 11; // not exact - google.protobuf.Timestamp last_active = 12; // not exact - org.couchers.api.core.HostingStatus hosting_status = 13; - org.couchers.api.core.MeetupStatus meetup_status = 102; - string occupation = 14; - string education = 103; // CommonMark without images - string about_me = 15; // CommonMark without images - string my_travels = 104; // CommonMark without images - string things_i_like = 105; // CommonMark without images - string about_place = 16; // CommonMark without images - repeated string regions_visited = 40; - repeated string regions_lived = 41; - string additional_information = 106; // CommonMark without images - - // FriendshipStatus friends = 20; - // only present if friends == FriendshipStatus.PENDING - // FriendRequest pending_friend_request = 36; - - google.protobuf.UInt32Value max_guests = 22; - google.protobuf.BoolValue last_minute = 24; - google.protobuf.BoolValue has_pets = 107; - google.protobuf.BoolValue accepts_pets = 25; - google.protobuf.StringValue pet_details = 108; - google.protobuf.BoolValue has_kids = 109; - google.protobuf.BoolValue accepts_kids = 26; - google.protobuf.StringValue kid_details = 110; - google.protobuf.BoolValue has_housemates = 111; - google.protobuf.StringValue housemate_details = 112; - google.protobuf.BoolValue wheelchair_accessible = 27; - org.couchers.api.core.SmokingLocation smoking_allowed = 28; - google.protobuf.BoolValue smokes_at_home = 113; - google.protobuf.BoolValue drinking_allowed = 114; - google.protobuf.BoolValue drinks_at_home = 115; - google.protobuf.StringValue other_host_info = 116; // CommonMark without images - org.couchers.api.core.SleepingArrangement sleeping_arrangement = 117; - google.protobuf.StringValue sleeping_details = 118; // CommonMark without images - google.protobuf.StringValue area = 30; // CommonMark without images - google.protobuf.StringValue house_rules = 31; // CommonMark without images - google.protobuf.BoolValue parking = 119; - org.couchers.api.core.ParkingDetails parking_details = 120; // CommonMark without images - google.protobuf.BoolValue camping_ok = 121; - - string avatar_url = 32; - string avatar_thumbnail_url = 44; - repeated org.couchers.api.core.LanguageAbility language_abilities = 37; - - repeated string badges = 38; - - bool has_strong_verification = 39; - org.couchers.api.core.BirthdateVerificationStatus birthdate_verification_status = 42; - org.couchers.api.core.GenderVerificationStatus gender_verification_status = 43; + oneof profile { + LimitedUser limited_user = 1; + MostUser most_user = 2; + org.couchers.api.core.User full_user = 3; + } } diff --git a/app/web/features/auth/locales/en.json b/app/web/features/auth/locales/en.json index 6f8676d747..47003c3469 100644 --- a/app/web/features/auth/locales/en.json +++ b/app/web/features/auth/locales/en.json @@ -247,9 +247,9 @@ "choose": "Choose what information will be visible to logged-out users", "visiblility_options": { "nothing": "<1>Nothing: hide me completely", - "map_only": "<1>Map only: show a map pin but not my profile", - "limited": "<1>Limited: show my name, gender, location, hosting/meetup status, badges, number of references, and how long I've been a Coucher", - "most": "<1>Most: show full \"About me\" section (including my picture) except \"Additional information\"", + "map_only": "<1>Map only: show an anonymous map pin but not a profile page", + "limited": "<1>Limited: show my name, location, hosting/meetup status, badges, number of references, and how long I've been a Coucher", + "most": "<1>Most: show full \"About me\" section except \"Additional information\"", "full_profile": "<1>Full profile: show my full profile (except references)" } } diff --git a/app/web/features/auth/visibility/ProfileVisibility.tsx b/app/web/features/auth/visibility/ProfileVisibility.tsx index 941467c321..068d0b9113 100644 --- a/app/web/features/auth/visibility/ProfileVisibility.tsx +++ b/app/web/features/auth/visibility/ProfileVisibility.tsx @@ -11,10 +11,8 @@ import { Empty } from "google-protobuf/google/protobuf/empty_pb"; import { RpcError } from "grpc-web"; import { TFunction, Trans, useTranslation } from "i18n"; import { AUTH, GLOBAL } from "i18n/namespaces"; -import { - GetAccountInfoRes, - ProfilePublicVisibilitySetting, -} from "proto/account_pb"; +import { GetAccountInfoRes, ProfilePublicVisibility } from "proto/account_pb"; +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { useMutation, useQueryClient } from "react-query"; import { service } from "service"; @@ -31,7 +29,7 @@ export default function ProfileVisibility({ const { t } = useTranslation([GLOBAL, AUTH]); const { handleSubmit, reset, control } = - useForm<{ choice: ProfilePublicVisibilitySetting }>(); + useForm<{ choice: ProfilePublicVisibility }>(); const onSubmit = handleSubmit(({ choice }) => { mutate(choice); @@ -41,37 +39,40 @@ export default function ProfileVisibility({ const { error, isLoading, mutate } = useMutation< Empty, RpcError, - ProfilePublicVisibilitySetting + ProfilePublicVisibility >(service.account.setProfilePublicVisibility, { onSuccess: () => { queryClient.invalidateQueries(accountInfoQueryKey); - reset(); }, }); const choices: [number, Parameters>[0]][] = [ [ - ProfilePublicVisibilitySetting.PROFILE_PUBLIC_VISIBILITY_SETTING_NOTHING, + ProfilePublicVisibility.PROFILE_PUBLIC_VISIBILITY_NOTHING, "auth:profile_visibility.visiblility_options.nothing", ], [ - ProfilePublicVisibilitySetting.PROFILE_PUBLIC_VISIBILITY_SETTING_MAP_ONLY, + ProfilePublicVisibility.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY, "auth:profile_visibility.visiblility_options.map_only", ], [ - ProfilePublicVisibilitySetting.PROFILE_PUBLIC_VISIBILITY_SETTING_LIMITED, + ProfilePublicVisibility.PROFILE_PUBLIC_VISIBILITY_LIMITED, "auth:profile_visibility.visiblility_options.limited", ], [ - ProfilePublicVisibilitySetting.PROFILE_PUBLIC_VISIBILITY_SETTING_MOST, + ProfilePublicVisibility.PROFILE_PUBLIC_VISIBILITY_MOST, "auth:profile_visibility.visiblility_options.most", ], [ - ProfilePublicVisibilitySetting.PROFILE_PUBLIC_VISIBILITY_SETTING_FULL, + ProfilePublicVisibility.PROFILE_PUBLIC_VISIBILITY_FULL, "auth:profile_visibility.visiblility_options.full_profile", ], ]; + useEffect(() => { + reset({ choice: accountInfo.profilePublicVisibility }); + }, [accountInfo, reset]); + return (
{t("auth:profile_visibility.title")} @@ -82,32 +83,31 @@ export default function ProfileVisibility({
( - onChange(Number(event.target.value))} - > - {choices.map(([setting, translationKey]) => ( - } - label={ - }} - /> - } - /> - ))} - + onChange(Number(event.target.value))} + > + {choices.map(([setting, translationKey]) => ( + } + label={ + }} + /> + } + /> + ))} + )} /> -