Skip to content

Commit 5610aa7

Browse files
authored
Merge pull request #1251 from jboolean/require-email
Require email to access map
2 parents cd6f783 + 7f9804c commit 5610aa7

File tree

11 files changed

+164
-32
lines changed

11 files changed

+164
-32
lines changed

backend/src/api/AuthenticationController.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ type LoginRequest = {
3030
requireVerifiedEmail?: boolean;
3131

3232
// If the user is already logged in, and the email is different, should we update the email on a named account?
33-
newEmailBehavior?: 'update' | 'reject';
33+
newEmailBehavior?: 'update' | 'reject' | 'create';
3434
};
3535

3636
type LoginResponse = {

backend/src/business/users/UserService.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ export async function processLoginRequest(
225225
apiBase: string,
226226
returnToPath?: string,
227227
requireVerifiedEmail = false,
228-
newEmailBehavior: 'update' | 'reject' = 'update'
228+
newEmailBehavior: 'update' | 'reject' | 'create' = 'update'
229229
): Promise<LoginOutcome> {
230230
const userRepository = getRepository(User);
231231

@@ -265,7 +265,27 @@ export async function processLoginRequest(
265265
);
266266
return LoginOutcome.SentLinkToExistingAccount;
267267
} else {
268-
if (newEmailBehavior === 'update' || currentUser.isAnonymous) {
268+
if (newEmailBehavior === 'create' && !currentUser.isAnonymous) {
269+
// Create a new account for the new email if current account is non-anonymous
270+
const newUser = new User();
271+
newUser.email = normalizeEmail(requestedEmail);
272+
newUser.isEmailVerified = false;
273+
newUser.ipAddress = currentUser.ipAddress; // Keep the same IP for tracking
274+
await userRepository.save(newUser);
275+
276+
if (requireVerifiedEmail) {
277+
// We require a verified email for the new account
278+
await sendMagicLink(
279+
required(newUser.email, 'email'),
280+
newUser.id,
281+
apiBase,
282+
returnToPath
283+
);
284+
return LoginOutcome.SentLinkToVerifyEmail;
285+
}
286+
287+
return LoginOutcome.CreatedNewAccount as LoginOutcome;
288+
} else if (newEmailBehavior === 'update' || currentUser.isAnonymous) {
269289
// Either account is anonymous or the current user is changing their email
270290
// Stays logged in, but updates account info
271291
currentUser.email = normalizeEmail(requestedEmail);

backend/src/enum/LoginOutcome.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ enum LoginOutcome {
44
SentLinkToExistingAccount = 'sent_link_to_existing_account',
55
SentLinkToVerifyEmail = 'sent_link_to_verify_email',
66
AccountDoesNotExist = 'account_does_not_exist',
7+
CreatedNewAccount = 'created_new_account',
78
}
89

910
export default LoginOutcome;

frontend/src/screens/App/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import stylesheet from './App.less';
1414
import AllStories from './screens/AllStories';
1515
import Outtakes from './screens/Outtakes';
1616

17+
import useLoginStore from 'shared/stores/LoginStore';
1718
import { OptimizeExperimentsProvider } from 'shared/utils/OptimizeExperiments';
1819
import 'utils/optimize';
1920
import AdminRoutes from './screens/Admin/AdminRoutes';
@@ -117,6 +118,12 @@ function ContextWrappers({
117118
}: {
118119
children: React.ReactNode;
119120
}): JSX.Element {
121+
const initialize = useLoginStore((state) => state.initialize);
122+
123+
React.useEffect(() => {
124+
initialize();
125+
}, [initialize]);
126+
120127
return <OptimizeExperimentsProvider>{children}</OptimizeExperimentsProvider>;
121128
}
122129

frontend/src/screens/App/screens/AnnouncementBanner/AnnouncementRegistry.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import React from 'react';
22
import Announcment from './Announcement';
33

44
const ANNOUNCEMENTS_REGISTRY: Announcment[] = [
5+
{
6+
id: 'email-required',
7+
expiresAt: new Date('2026-01-18'),
8+
render: () => (
9+
<React.Fragment>
10+
Due to high usage, an email address is temporarily required to access
11+
the map.
12+
</React.Fragment>
13+
),
14+
},
515
{
616
id: 'zoom',
717
expiresAt: new Date('2025-01-31'),

frontend/src/screens/App/screens/MapPane/components/MainMap/index.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
import classnames from 'classnames';
1111
import mapboxgl from 'mapbox-gl';
1212

13+
import LoginForm from 'shared/components/LoginForm';
14+
import useLoginStore from 'shared/stores/LoginStore';
1315
import * as overlays from './overlays';
1416

1517
export { OverlayId } from './overlays';
@@ -153,6 +155,37 @@ function withRouterRef<
153155
const match = useRouteMatch();
154156
const location = useLocation();
155157
const history = useHistory();
158+
const { isLoggedInToNonAnonymousAccount, isLoadingMe } = useLoginStore();
159+
160+
// Only show map if user is logged in to a non-anonymous account
161+
const canShowMap = isLoggedInToNonAnonymousAccount && !isLoadingMe;
162+
163+
if (!canShowMap) {
164+
return (
165+
<div
166+
style={{
167+
height: '100%',
168+
display: 'flex',
169+
alignItems: 'center',
170+
justifyContent: 'center',
171+
backgroundColor: '#f5f5f5',
172+
padding: '2rem',
173+
}}
174+
>
175+
<div
176+
style={{
177+
textAlign: 'center',
178+
maxWidth: '400px',
179+
}}
180+
>
181+
<h2>Login Required</h2>
182+
<p>Please log in to view the map.</p>
183+
<LoginForm newEmailBehavior="create" />
184+
</div>
185+
</div>
186+
);
187+
}
188+
156189
return (
157190
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- I give up on types
158191
// @ts-ignore

frontend/src/screens/App/screens/Welcome/index.tsx

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import React from 'react';
22
import { useFeatureFlag } from 'screens/App/shared/stores/FeatureFlagsStore';
33
import FeatureFlag from 'screens/App/shared/types/FeatureFlag';
44
import Button from 'shared/components/Button';
5+
import LoginForm from 'shared/components/LoginForm';
6+
import useLoginStore from 'shared/stores/LoginStore';
57

68
import PhotoAsideModal from '../PhotoAsideModal';
79
import carouselImages from './carouselImages';
@@ -10,6 +12,9 @@ import classNames from 'classnames';
1012

1113
import stylesheet from './welcome.less';
1214

15+
// Set to false to disable email requirement
16+
const REQUIRE_EMAIL = true;
17+
1318
interface Props {
1419
isOpen: boolean;
1520
onRequestClose: () => void;
@@ -21,13 +26,26 @@ export default function Welcome({
2126
// Used to hide this annoying modal in development
2227
const isWelcomeDisabled = useFeatureFlag(FeatureFlag.DISABLE_WELCOME_MODAL);
2328

29+
const { isLoggedInToNonAnonymousAccount, isLoadingMe } = useLoginStore();
30+
31+
// Only require login if REQUIRE_EMAIL is true
32+
const isEmailRequirementMet = REQUIRE_EMAIL
33+
? isLoggedInToNonAnonymousAccount && !isLoadingMe
34+
: true;
35+
36+
const handleClose = (): void => {
37+
if (isEmailRequirementMet) {
38+
onRequestClose();
39+
}
40+
};
41+
2442
return (
2543
<PhotoAsideModal
2644
isOpen={isOpen && !isWelcomeDisabled}
2745
className={stylesheet.welcomeModal}
28-
onRequestClose={onRequestClose}
46+
onRequestClose={handleClose}
2947
shouldCloseOnOverlayClick={false}
30-
shouldCloseOnEsc
48+
shouldCloseOnEsc={isEmailRequirementMet}
3149
size="large"
3250
isCloseButtonVisible={false}
3351
carouselProps={{
@@ -48,18 +66,31 @@ export default function Welcome({
4866
<br />
4967
<strong>Zoom in! Every dot&nbsp;is&nbsp;a&nbsp;photo.</strong>
5068
</p>
51-
<div
52-
className={classNames(
53-
stylesheet.buttonContainer,
54-
stylesheet.mobileButton,
55-
stylesheet.mobileOnly
56-
)}
57-
onClick={onRequestClose}
58-
>
59-
<Button buttonStyle="primary" className={stylesheet.explore}>
60-
Start Exploring
61-
</Button>
62-
</div>
69+
70+
{REQUIRE_EMAIL && !isEmailRequirementMet ? (
71+
<div className={stylesheet.loginSection}>
72+
<p>
73+
<strong>
74+
An email address is temporarily required to access the site.
75+
</strong>
76+
</p>
77+
<LoginForm newEmailBehavior="create" />
78+
</div>
79+
) : (
80+
<div
81+
className={classNames(
82+
stylesheet.buttonContainer,
83+
stylesheet.mobileButton,
84+
stylesheet.mobileOnly
85+
)}
86+
onClick={handleClose}
87+
>
88+
<Button buttonStyle="primary" className={stylesheet.explore}>
89+
Start Exploring
90+
</Button>
91+
</div>
92+
)}
93+
6394
<hr />
6495
<p className={stylesheet.finePrint}>
6596
The photos on this site were retrieved from the NYC Department of
@@ -108,17 +139,19 @@ export default function Welcome({
108139
</a>
109140
</p>
110141
</div>
111-
<div
112-
className={classNames(
113-
stylesheet.buttonContainer,
114-
stylesheet.desktopOnly
115-
)}
116-
onClick={onRequestClose}
117-
>
118-
<Button buttonStyle="primary" className={stylesheet.explore}>
119-
Start Exploring
120-
</Button>
121-
</div>
142+
{(!REQUIRE_EMAIL || isEmailRequirementMet) && (
143+
<div
144+
className={classNames(
145+
stylesheet.buttonContainer,
146+
stylesheet.desktopOnly
147+
)}
148+
onClick={handleClose}
149+
>
150+
<Button buttonStyle="primary" className={stylesheet.explore}>
151+
Start Exploring
152+
</Button>
153+
</div>
154+
)}
122155
</div>
123156
</PhotoAsideModal>
124157
);

frontend/src/screens/App/screens/Welcome/welcome.less

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646
font-size: 12px;
4747
}
4848

49+
.loginSection {
50+
margin: 1rem 0;
51+
text-align: center;
52+
}
53+
4954
.buttonContainer {
5055
padding: 1rem;
5156
text-align: center;

frontend/src/shared/components/LoginForm/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default function LoginForm({
1212
newEmailBehavior,
1313
}: {
1414
requireVerifiedEmail?: boolean;
15-
newEmailBehavior?: 'update' | 'reject';
15+
newEmailBehavior?: 'update' | 'reject' | 'create';
1616
}): JSX.Element {
1717
const {
1818
emailAddress,
@@ -23,6 +23,7 @@ export default function LoginForm({
2323
isVerifyEmailMessageVisible,
2424
isEmailUpdatedMessageVisible,
2525
isAccountDoesNotExistMessageVisible,
26+
isNewAccountCreatedMessageVisible,
2627
isLoadingMe,
2728
} = useLoginStore();
2829

@@ -83,6 +84,11 @@ export default function LoginForm({
8384
No account found for <i>{emailAddress}</i>.
8485
</p>
8586
)}
87+
{isNewAccountCreatedMessageVisible && (
88+
<p className={stylesheet.resultMessage}>
89+
A new account has been created for <i>{emailAddress}</i>.
90+
</p>
91+
)}
8692
</form>
8793
);
8894
}

frontend/src/shared/stores/LoginStore.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import {
1010
interface State {
1111
emailAddress: string;
1212
isLoginValidated: boolean;
13+
isLoggedInToNonAnonymousAccount: boolean;
1314
isFollowMagicLinkMessageVisible: boolean;
1415
isVerifyEmailMessageVisible: boolean;
1516
isEmailUpdatedMessageVisible: boolean;
1617
isAccountDoesNotExistMessageVisible: boolean;
18+
isNewAccountCreatedMessageVisible: boolean;
1719
isLoadingMe: boolean;
1820
}
1921

@@ -25,7 +27,7 @@ interface Actions {
2527
newEmailBehavior,
2628
}: {
2729
requireVerifiedEmail: boolean;
28-
newEmailBehavior?: 'update' | 'reject';
30+
newEmailBehavior?: 'update' | 'reject' | 'create';
2931
}) => void;
3032
logout: () => void;
3133
}
@@ -34,24 +36,30 @@ const useLoginStore = create(
3436
immer<State & Actions>((set, get) => ({
3537
emailAddress: '',
3638
isLoginValidated: false,
39+
isLoggedInToNonAnonymousAccount: false,
3740
isFollowMagicLinkMessageVisible: false,
3841
isVerifyEmailMessageVisible: false,
3942
isEmailUpdatedMessageVisible: false,
4043
isAccountDoesNotExistMessageVisible: false,
44+
isNewAccountCreatedMessageVisible: false,
4145
isLoadingMe: false,
4246

4347
initialize: () => {
4448
set((draft) => {
4549
draft.isLoginValidated = false;
50+
draft.isLoggedInToNonAnonymousAccount = false;
4651
draft.isLoadingMe = true;
4752
});
4853
getMe()
4954
.then((me) => {
5055
set((draft) => {
5156
draft.emailAddress = me.email || '';
57+
// If getMe returns an email, the user is logged in to a non-anonymous account
58+
draft.isLoggedInToNonAnonymousAccount = !!me.email;
5259
draft.isFollowMagicLinkMessageVisible = false;
5360
draft.isVerifyEmailMessageVisible = false;
5461
draft.isEmailUpdatedMessageVisible = false;
62+
draft.isNewAccountCreatedMessageVisible = false;
5563
});
5664
})
5765
.catch((err: unknown) => {
@@ -70,6 +78,7 @@ const useLoginStore = create(
7078
draft.isFollowMagicLinkMessageVisible = false;
7179
draft.isEmailUpdatedMessageVisible = false;
7280
draft.isLoginValidated = false;
81+
// Don't reset isLoggedInToNonAnonymousAccount here as it's based on the server state
7382
});
7483
},
7584

@@ -89,11 +98,13 @@ const useLoginStore = create(
8998
);
9099
if (
91100
outcome === LoginOutcome.AlreadyAuthenticated ||
92-
outcome === LoginOutcome.UpdatedEmailOnAuthenticatedAccount
101+
outcome === LoginOutcome.UpdatedEmailOnAuthenticatedAccount ||
102+
outcome === LoginOutcome.CreatedNewAccount
93103
) {
94104
set((draft) => {
95105
// We stay logged into the current account and can proceed
96106
draft.isLoginValidated = true;
107+
draft.isLoggedInToNonAnonymousAccount = true;
97108
draft.isFollowMagicLinkMessageVisible = false;
98109
draft.isVerifyEmailMessageVisible = false;
99110
});
@@ -102,6 +113,11 @@ const useLoginStore = create(
102113
draft.isEmailUpdatedMessageVisible = true;
103114
});
104115
}
116+
if (outcome === LoginOutcome.CreatedNewAccount) {
117+
set((draft) => {
118+
draft.isNewAccountCreatedMessageVisible = true;
119+
});
120+
}
105121
} else if (outcome === LoginOutcome.SentLinkToExistingAccount) {
106122
set((draft) => {
107123
// The user must follow the link to log into another account, or verify their email

0 commit comments

Comments
 (0)