Skip to content

Commit c26eb09

Browse files
authored
Merge pull request #3226 from codecrafters-io/clear-local-storage-on-logout
Clear local storage when logging out
2 parents 8afe985 + affedad commit c26eb09

File tree

3 files changed

+177
-23
lines changed

3 files changed

+177
-23
lines changed

app/services/authenticator.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import Store from '@ember-data/store';
77
import window from 'ember-window-mock';
88
import { tracked } from '@glimmer/tracking';
99
import type UserModel from 'codecrafters-frontend/models/user';
10+
import type LocalStorageService from 'codecrafters-frontend/services/local-storage';
1011

1112
export default class AuthenticatorService extends Service {
1213
@service declare router: RouterService;
1314
@service declare sessionTokenStorage: SessionTokenStorageService;
1415
@service declare currentUserCacheStorage: CurrentUserCacheStorageService;
16+
@service declare localStorage: LocalStorageService;
1517
@service declare store: Store;
1618

1719
// TODO: See if there's a way around using this
@@ -91,6 +93,8 @@ export default class AuthenticatorService extends Service {
9193
this.sessionTokenStorage.clear();
9294
// eslint-disable-next-line ember/no-array-prototype-extensions
9395
this.currentUserCacheStorage.clear();
96+
// eslint-disable-next-line ember/no-array-prototype-extensions
97+
this.localStorage.clear();
9498
this.cacheBuster++;
9599
}
96100

app/services/local-storage.ts

Lines changed: 95 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,127 @@
11
import Service from '@ember/service';
22

3+
/**
4+
* Returns the localStorage key with application-specific prefix applied.
5+
*
6+
* @param {string} [key=''] - The unprefixed key.
7+
* @returns {string} The prefixed key used in window.localStorage.
8+
*/
9+
function prefixKey(key: string = ''): string {
10+
return `cc-frontend:${key}`;
11+
}
12+
13+
/**
14+
* Collects and returns all keys from window.localStorage that start with the
15+
* application prefix. Returned keys are unprefixed (prefix removed).
16+
*
17+
* @returns {string[]} Array of keys stored under the application prefix.
18+
*/
19+
function getLocalStorageKeys(): string[] {
20+
return Array.from({ length: window.localStorage?.length || 0 }, (_, i) => window.localStorage?.key(i) || null)
21+
.filter((k) => k !== null)
22+
.filter((k) => k.startsWith(prefixKey()))
23+
.map((key) => key.substring(prefixKey().length));
24+
}
25+
26+
/**
27+
* Service wrapping browser localStorage access and applying an app-specific prefix
28+
* to all keys. Provides safe guards for environments where window.localStorage
29+
* may be unavailable and convenience methods for common operations.
30+
*/
331
export default class LocalStorageService extends Service {
4-
get length(): number {
5-
if (!window.localStorage) {
6-
return 0;
7-
}
32+
/**
33+
* Initialize the service and perform cleanup of known legacy keys.
34+
* TODO: legacy key removal is temporary and can be removed after migration.
35+
*/
36+
constructor() {
37+
super();
838

9-
return window.localStorage.length;
39+
// TODO: Remove legacy keys from local storage - safe to drop a month after merge
40+
this.clearLegacyKeys();
1041
}
1142

43+
/**
44+
* Number of prefixed keys available in window.localStorage.
45+
*/
46+
get length(): number {
47+
return getLocalStorageKeys().length;
48+
}
49+
50+
/**
51+
* Remove all prefixed keys from window.localStorage.
52+
*/
1253
clear(): void {
13-
if (!window.localStorage) {
14-
throw new Error('Clearing localStorage items is unavailable in the current context');
54+
for (const key of getLocalStorageKeys()) {
55+
this.removeItem(key);
1556
}
57+
}
1658

17-
window.localStorage.clear();
59+
/**
60+
* Remove known legacy keys that were stored without the current prefix.
61+
* This is a one-time migration helper.
62+
*/
63+
clearLegacyKeys() {
64+
for (const key of [
65+
'current_user_cache_v1:user_id',
66+
'current_user_cache_v1:username',
67+
'preferred-language-leaderboard-v1',
68+
'leaderboard-team-selection-v1',
69+
'session_token_v1',
70+
]) {
71+
window.localStorage?.removeItem(key);
72+
}
1873
}
1974

20-
getItem(keyName: string): string | null {
75+
/**
76+
* Get a value from localStorage for the given key (unprefixed).
77+
*
78+
* @param {string} key - The unprefixed key to retrieve.
79+
* @returns {string | null} The stored value, or null if not found or unavailable.
80+
*/
81+
getItem(key: string): string | null {
2182
if (!window.localStorage) {
2283
return null;
2384
}
2485

25-
return window.localStorage.getItem(keyName);
86+
return window.localStorage.getItem(prefixKey(key));
2687
}
2788

89+
/**
90+
* Retrieve the unprefixed key at the given index.
91+
*
92+
* @param {number} index - Index of the key to retrieve.
93+
* @returns {string | null} The unprefixed key or null if out of range.
94+
*/
2895
key(index: number): string | null {
29-
if (!window.localStorage) {
30-
return null;
31-
}
32-
33-
return window.localStorage.key(index);
96+
return getLocalStorageKeys()[index] || null;
3497
}
3598

36-
removeItem(keyName: string): void {
99+
/**
100+
* Remove the item associated with the given unprefixed key from localStorage.
101+
*
102+
* @param {string} key - The unprefixed key to remove.
103+
* @throws Will throw if window.localStorage is not available.
104+
*/
105+
removeItem(key: string): void {
37106
if (!window.localStorage) {
38107
throw new Error('Removing localStorage items is unavailable in the current context');
39108
}
40109

41-
window.localStorage.removeItem(keyName);
110+
window.localStorage.removeItem(prefixKey(key));
42111
}
43112

44-
setItem(keyName: string, keyValue: string): void {
113+
/**
114+
* Set an item in localStorage under the given unprefixed key.
115+
*
116+
* @param {string} key - The unprefixed key under which to store the value.
117+
* @param {string} value - The string value to store.
118+
* @throws Will throw if window.localStorage is not available.
119+
*/
120+
setItem(key: string, value: string): void {
45121
if (!window.localStorage) {
46122
throw new Error('Setting localStorage items is unavailable in the current context');
47123
}
48124

49-
window.localStorage.setItem(keyName, keyValue);
125+
window.localStorage.setItem(prefixKey(key), value);
50126
}
51127
}

tests/unit/services/local-storage-test.js

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,83 @@ import { setupTest } from 'codecrafters-frontend/tests/helpers';
44
module('Unit | Service | local-storage', function (hooks) {
55
setupTest(hooks);
66

7-
// TODO: Replace this with your real tests.
8-
test('it exists', function (assert) {
9-
let service = this.owner.lookup('service:local-storage');
10-
assert.ok(service);
7+
test('getItem / setItem / removeItem use prefixed keys', function (assert) {
8+
const service = this.owner.lookup('service:local-storage');
9+
10+
service.setItem('foo', 'bar');
11+
12+
assert.strictEqual(window.localStorage.getItem('cc-frontend:foo'), 'bar', 'raw storage has prefixed key');
13+
assert.strictEqual(service.getItem('foo'), 'bar', 'service returns stored value');
14+
15+
service.removeItem('foo');
16+
assert.strictEqual(service.getItem('foo'), null, 'value removed via service');
17+
assert.strictEqual(window.localStorage.getItem('cc-frontend:foo'), null, 'raw storage entry removed');
18+
});
19+
20+
test('length and key return prefixed keys only', function (assert) {
21+
const service = this.owner.lookup('service:local-storage');
22+
23+
// add two prefixed keys and one non-prefixed
24+
service.setItem('one', '1');
25+
service.setItem('two', '2');
26+
window.localStorage.setItem('three', '3');
27+
28+
assert.strictEqual(service.length, 2, 'service.length counts only prefixed keys');
29+
30+
const keys = [];
31+
32+
for (let i = 0; i < service.length; i++) {
33+
keys.push(service.key(i));
34+
}
35+
36+
// keys should be unprefixed
37+
assert.ok(keys.includes('one'), 'contains one');
38+
assert.ok(keys.includes('two'), 'contains two');
39+
assert.notOk(keys.includes('three'), 'does not include non-prefixed key');
40+
});
41+
42+
test('clear removes only prefixed keys and leaves others intact', function (assert) {
43+
const service = this.owner.lookup('service:local-storage');
44+
45+
service.setItem('temp', 'x');
46+
window.localStorage.setItem('other:keep', 'y');
47+
48+
// eslint-disable-next-line ember/no-array-prototype-extensions
49+
service.clear();
50+
51+
// prefixed keys removed
52+
assert.strictEqual(service.getItem('existing'), null, 'existing prefixed removed');
53+
assert.strictEqual(service.getItem('temp'), null, 'temp prefixed removed');
54+
55+
// non-prefixed untouched
56+
assert.strictEqual(window.localStorage.getItem('other:keep'), 'y', 'non-prefixed key remains');
57+
});
58+
59+
test('clearLegacyKeys removes legacy keys from raw localStorage', function (assert) {
60+
const initial = {
61+
'cc-frontend:existing': 'yes',
62+
'external:keep': 'stay',
63+
// legacy keys that clearLegacyKeys should remove
64+
'current_user_cache_v1:user_id': '1',
65+
'current_user_cache_v1:username': 'u',
66+
'preferred-language-leaderboard-v1': 'en',
67+
'leaderboard-team-selection-v1': 'team',
68+
session_token_v1: 'token',
69+
};
70+
71+
for (const key of Object.keys(initial)) {
72+
window.localStorage.setItem(key, initial[key]);
73+
}
74+
75+
const service = this.owner.lookup('service:local-storage');
76+
service.clearLegacyKeys();
77+
78+
assert.strictEqual(window.localStorage.getItem('cc-frontend:existing'), 'yes', 'cc-frontend:existing is preserved');
79+
assert.strictEqual(window.localStorage.getItem('external:keep'), 'stay', 'external:keep is preserved');
80+
assert.strictEqual(window.localStorage.getItem('current_user_cache_v1:user_id'), null, 'legacy user_id removed');
81+
assert.strictEqual(window.localStorage.getItem('current_user_cache_v1:username'), null, 'legacy username removed');
82+
assert.strictEqual(window.localStorage.getItem('preferred-language-leaderboard-v1'), null, 'legacy preferred-language removed');
83+
assert.strictEqual(window.localStorage.getItem('leaderboard-team-selection-v1'), null, 'legacy leaderboard-team-selection removed');
84+
assert.strictEqual(window.localStorage.getItem('session_token_v1'), null, 'legacy session_token removed');
1185
});
1286
});

0 commit comments

Comments
 (0)