Skip to content

Commit 6ad1a00

Browse files
committed
Add 'keep me logged in' option [#7]
1 parent cdf0cf6 commit 6ad1a00

File tree

10 files changed

+83
-28
lines changed

10 files changed

+83
-28
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { CacheMap } from '../helpers/CacheMap';
2+
import { AsyncValue } from '../helpers/AsyncValue';
3+
import type { RetroAuth } from '../shared/api-entities';
4+
import { store } from '../helpers/storage';
5+
6+
const MINIMUM_VALIDITY = 1000 * 60 * 60 * 12;
7+
8+
export class RetroAuthTracker extends CacheMap<string, AsyncValue<RetroAuth>> {
9+
public constructor() {
10+
super((retroId) => {
11+
const value = new AsyncValue<RetroAuth>();
12+
try {
13+
const stored = store.getItem(storageKey(retroId));
14+
if (stored) {
15+
const auth: RetroAuth = JSON.parse(stored);
16+
if (Date.now() + MINIMUM_VALIDITY < auth.expires) {
17+
value.set(auth);
18+
}
19+
}
20+
} catch {}
21+
return value;
22+
});
23+
}
24+
25+
public set(retroId: string, auth: RetroAuth, persist?: boolean) {
26+
if (persist) {
27+
store.setItem(storageKey(retroId), JSON.stringify(auth));
28+
} else if (persist === false) {
29+
store.removeItem(storageKey(retroId));
30+
}
31+
this.get(retroId).set(auth);
32+
}
33+
}
34+
35+
const storageKey = (retroId: string) => `retro-token-${retroId}`;

frontend/src/api/api.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { RetroAuth, UserData } from '../shared/api-entities';
1+
import type { UserData } from '../shared/api-entities';
22
import { ConfigService } from './ConfigService';
33
import { RetroListTracker } from './RetroListTracker';
44
import { SlugTracker } from './SlugTracker';
@@ -10,7 +10,7 @@ import { RetroService } from './RetroService';
1010
import { PasswordService } from './PasswordService';
1111
import { GiphyService } from './GiphyService';
1212
import { AsyncValue } from '../helpers/AsyncValue';
13-
import { AsyncValueMap } from '../helpers/AsyncValueMap';
13+
import { RetroAuthTracker } from './RetroAuthTracker';
1414
import { DiagnosticsService } from './DiagnosticsService';
1515

1616
const { protocol, host } = document.location;
@@ -31,5 +31,5 @@ export const userDataService = new UserDataService(API_BASE);
3131
export const passwordService = new PasswordService(API_BASE);
3232
export const giphyService = new GiphyService(API_BASE);
3333

34-
export const retroAuthTracker = new AsyncValueMap<string, RetroAuth>();
34+
export const retroAuthTracker = new RetroAuthTracker();
3535
export const userDataTracker = new AsyncValue<UserData | null>();

frontend/src/components/RetroRouter.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,15 @@ export const RetroRouter: FC<PropsT> = ({ slug }) => {
4747
return <div className="loader error">{slugError.message}</div>;
4848
}
4949

50-
if (retroId && !retroAuth) {
50+
if (!retroId) {
51+
return <div className="loader">Loading&hellip;</div>;
52+
}
53+
54+
if (!retroAuth || (!retro && status === 'reauthenticate')) {
5155
return <PasswordPage slug={slug} retroId={retroId} />;
5256
}
5357

54-
if (!retroId || !retro || !retroAuth) {
58+
if (!retro) {
5559
return <div className="loader">Loading&hellip;</div>;
5660
}
5761

frontend/src/components/login/LoginCallback.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { useState, useEffect, memo } from 'react';
22
import { useLocation } from 'wouter';
33
import { handleLogin } from './handleLogin';
44
import { useEvent } from '../../hooks/useEvent';
5+
import { sessionStore } from '../../helpers/storage';
56
import { Header } from '../common/Header';
6-
import { storage } from './storage';
77
import './LoginCallback.css';
88

99
interface PropsT {
@@ -20,13 +20,13 @@ export const LoginCallback = memo(({ service }: PropsT) => {
2020
const ac = new AbortController();
2121
const { search, hash } = document.location;
2222
const redirectUri = document.location.href.split('?')[0]!;
23-
const localState = storage.getItem('login-state');
23+
const localState = sessionStore.getItem('login-state');
2424
handleLogin(service, localState, { search, hash, redirectUri }, ac.signal)
2525
.then((redirect) => {
2626
if (ac.signal.aborted) {
2727
return;
2828
}
29-
storage.removeItem('login-state');
29+
sessionStore.removeItem('login-state');
3030
if (
3131
new URL(redirect, document.location.href).host !==
3232
document.location.host
@@ -41,13 +41,13 @@ export const LoginCallback = memo(({ service }: PropsT) => {
4141
return;
4242
}
4343
if (!(err instanceof Error)) {
44-
storage.removeItem('login-state');
44+
sessionStore.removeItem('login-state');
4545
setError(String(err));
4646
} else if (err.message === 'unrecognised login details') {
4747
// GitLab shows a bare link to the /sso/login URL on the confirmation page
4848
stableSetLocation('/', { replace: true });
4949
} else {
50-
storage.removeItem('login-state');
50+
sessionStore.removeItem('login-state');
5151
setError(err.message);
5252
}
5353
});

frontend/src/components/login/LoginForm.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { memo, useLayoutEffect } from 'react';
22
import { useConfig } from '../../hooks/data/useConfig';
33
import { digest, randomBytes, toB64 } from '../../helpers/crypto';
4-
import { storage } from './storage';
4+
import { sessionStore } from '../../helpers/storage';
55
import './LoginForm.css';
66

77
function storeState(state: unknown) {
8-
if (!storage.setItem('login-state', JSON.stringify(state))) {
8+
if (!sessionStore.setItem('login-state', JSON.stringify(state))) {
99
window.alert(
1010
'You must enable session cookies or storage in your browser settings to log in',
1111
);

frontend/src/components/password/PasswordForm.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface PropsT {
99
}
1010

1111
export const PasswordForm = ({ slug, retroId }: PropsT): ReactElement => {
12+
const [rememberMe, setRememberMe] = useState(false);
1213
const [success, setSuccess] = useState(false);
1314
const [password, setPassword] = useState('');
1415

@@ -21,7 +22,7 @@ export const PasswordForm = ({ slug, retroId }: PropsT): ReactElement => {
2122
retroId,
2223
password,
2324
);
24-
retroAuthTracker.set(retroId, retroAuth);
25+
retroAuthTracker.set(retroId, retroAuth, rememberMe);
2526
setSuccess(true);
2627
});
2728

@@ -45,6 +46,15 @@ export const PasswordForm = ({ slug, retroId }: PropsT): ReactElement => {
4546
autoComplete="current-password"
4647
required
4748
/>
49+
<label className="checkbox">
50+
<input
51+
type="checkbox"
52+
checked={rememberMe}
53+
onChange={(e) => setRememberMe(e.currentTarget.checked)}
54+
autoComplete="off"
55+
/>
56+
Keep me logged in to this retro for 6 months
57+
</label>
4858
<button
4959
type="submit"
5060
className="wide-button"

frontend/src/components/security/SecurityPage.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ export const SecurityPage = memo(() => (
124124
exposing data. The session cookie is deleted as soon as the login is
125125
complete, or if the browser tab is closed.
126126
</p>
127+
<p>
128+
If "keep me logged in" is selected when entering the password for a
129+
retro, an authentication token will be saved to the local storage of the
130+
browser. This is only used for the purpose of keeping the user logged in
131+
to the retro without needing to re-enter the password each time.
132+
</p>
127133
<p>
128134
This service does not use any tracking, advertising, or third-party
129135
cookies or data storage.

frontend/src/helpers/AsyncValue.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ export class AsyncValue<T, Err = never> {
77
private state: ResolvedState<T, Err> = [null, null];
88
private loaderAC: AbortController | null = null;
99

10-
constructor() {}
11-
1210
static readonly EMPTY = new AsyncValue<never, never>();
1311

1412
static withProducer<T>(producer: (signal: AbortSignal) => Promise<T>) {

frontend/src/helpers/AsyncValueMap.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.
Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ function deleteCookie(key: string) {
7171
const getLocalStorage = () => window.localStorage;
7272
const getSessionStorage = () => window.sessionStorage;
7373

74-
export const storage = {
74+
export const sessionStore = {
7575
setItem(key: string, value: string): boolean {
7676
let any = false;
7777
// sessionStorage is best option for security and privacy
@@ -98,3 +98,17 @@ export const storage = {
9898
deleteCookie(key);
9999
},
100100
};
101+
102+
export const store = {
103+
setItem(key: string, value: string): boolean {
104+
return setStorage(getLocalStorage, key, value);
105+
},
106+
107+
getItem(key: string): string | null {
108+
return getStorage(getLocalStorage, key);
109+
},
110+
111+
removeItem(key: string) {
112+
deleteStorage(getLocalStorage, key);
113+
},
114+
};

0 commit comments

Comments
 (0)