Skip to content

Commit af10ef5

Browse files
committed
feat: Add encrypted credential storage and auto-fill for Hevy/Lyfta login forms
1 parent 5fed10b commit af10ef5

File tree

7 files changed

+276
-22
lines changed

7 files changed

+276
-22
lines changed

DEPLOYMENT.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,20 @@ If you ever want to restart onboarding:
192192
- Open DevTools
193193
- Application → Local Storage
194194
- Clear keys starting with `hevy_analytics_`
195+
- Also clear:
196+
- `hevy_username_or_email`
197+
- `hevy_analytics_secret:hevy_password`
198+
- `hevy_auth_token`
199+
- `lyfta_api_key`
200+
201+
If your browser is missing WebCrypto/IndexedDB support (or the page isn't a secure context), the app may fall back to storing passwords in Session Storage.
195202

196203

197204
## 5) Notes
198205

199206
- Hevy login is proxied through your backend.
200207
- The app stores the Hevy token in your browser (localStorage).
208+
- If you choose to use Hevy/Lyfta sync, the app may also store your login inputs locally to prefill onboarding (for example: username/email and API keys). Passwords are stored locally and are encrypted when the browser supports WebCrypto + IndexedDB.
201209
- Your workouts are processed client-side into `WorkoutSet[]`.
202210

203211

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,4 @@ If you find this project helpful, you can support it here:
189189

190190
- The only official deployment is https://liftshift.app.
191191
- Any other domain is unofficial. Do not enter credentials into an unofficial deployment.
192+
- LiftShift stores sync credentials locally in your browser (tokens, API keys, and login inputs). Passwords are encrypted at rest when the browser supports WebCrypto + IndexedDB.

frontend/App.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
saveSetupComplete,
6161
clearSetupComplete,
6262
} from './utils/storage/dataSourceStorage';
63+
import { clearHevyCredentials, saveHevyPassword, saveHevyUsernameOrEmail } from './utils/storage/hevyCredentialsStorage';
6364
import { hevyBackendGetAccount, hevyBackendGetSets, hevyBackendLogin } from './utils/api/hevyBackend';
6465
import { lyfatBackendGetSets } from './utils/api/lyfataBackend';
6566
import { parseHevyDateString } from './utils/date/parseHevyDateString';
@@ -194,6 +195,7 @@ const App: React.FC = () => {
194195
const clearCacheAndRestart = useCallback(() => {
195196
clearCSVData();
196197
clearHevyAuthToken();
198+
clearHevyCredentials();
197199
clearLyfataApiKey();
198200
clearDataSourceChoice();
199201
clearLastCsvPlatform();
@@ -744,7 +746,13 @@ const App: React.FC = () => {
744746
.then((r) => {
745747
if (!r.auth_token) throw new Error('Missing auth token');
746748
saveHevyAuthToken(r.auth_token);
747-
return hevyBackendGetAccount(r.auth_token).then(({ username }) => ({ token: r.auth_token, username }));
749+
const trimmed = emailOrUsername.trim();
750+
saveHevyUsernameOrEmail(trimmed);
751+
return Promise.all([
752+
saveHevyPassword(password).catch(() => {
753+
}),
754+
hevyBackendGetAccount(r.auth_token),
755+
]).then(([, { username }]) => ({ token: r.auth_token, username }));
748756
})
749757
.then(({ token, username }) => {
750758
setLoadingStep(1);

frontend/components/HevyLoginModal.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import React, { useState } from 'react';
1+
import React, { useEffect, useRef, useState } from 'react';
22
import { motion } from 'motion/react';
33
import { ArrowLeft, ArrowRight, HelpCircle, LogIn, RefreshCw, Trash2, Upload } from 'lucide-react';
44
import { UNIFORM_HEADER_BUTTON_CLASS, UNIFORM_HEADER_ICON_BUTTON_CLASS } from '../utils/ui/uiConstants';
5+
import {
6+
getHevyPassword,
7+
getHevyUsernameOrEmail,
8+
saveHevyUsernameOrEmail,
9+
} from '../utils/storage/hevyCredentialsStorage';
510

611
type Intent = 'initial' | 'update';
712

@@ -32,9 +37,25 @@ export const HevyLoginModal: React.FC<HevyLoginModalProps> = ({
3237
onBack,
3338
onClose,
3439
}) => {
35-
const [emailOrUsername, setEmailOrUsername] = useState('');
40+
const [emailOrUsername, setEmailOrUsername] = useState(() => getHevyUsernameOrEmail() || '');
3641
const [password, setPassword] = useState('');
3742
const [showLoginHelp, setShowLoginHelp] = useState(false);
43+
const passwordTouchedRef = useRef(false);
44+
45+
useEffect(() => {
46+
let cancelled = false;
47+
getHevyPassword()
48+
.then((p) => {
49+
if (cancelled) return;
50+
if (passwordTouchedRef.current) return;
51+
if (p) setPassword(p);
52+
})
53+
.catch(() => {
54+
});
55+
return () => {
56+
cancelled = true;
57+
};
58+
}, []);
3859

3960
return (
4061
<div className="fixed inset-0 z-50 bg-slate-950/90 backdrop-blur-sm overflow-y-auto overscroll-contain">
@@ -85,7 +106,9 @@ export const HevyLoginModal: React.FC<HevyLoginModalProps> = ({
85106
className="mt-5 space-y-3"
86107
onSubmit={(e) => {
87108
e.preventDefault();
88-
onLogin(emailOrUsername.trim(), password);
109+
const trimmed = emailOrUsername.trim();
110+
saveHevyUsernameOrEmail(trimmed);
111+
onLogin(trimmed, password);
89112
}}
90113
>
91114
{hasSavedSession && onSyncSaved ? (
@@ -103,6 +126,7 @@ export const HevyLoginModal: React.FC<HevyLoginModalProps> = ({
103126
<div>
104127
<label className="block text-xs font-semibold text-slate-200">Hevy username or email</label>
105128
<input
129+
name="username"
106130
value={emailOrUsername}
107131
onChange={(e) => setEmailOrUsername(e.target.value)}
108132
disabled={isLoading}
@@ -116,9 +140,13 @@ export const HevyLoginModal: React.FC<HevyLoginModalProps> = ({
116140
<div>
117141
<label className="block text-xs font-semibold text-slate-200">Password</label>
118142
<input
143+
name="password"
119144
type="password"
120145
value={password}
121-
onChange={(e) => setPassword(e.target.value)}
146+
onChange={(e) => {
147+
passwordTouchedRef.current = true;
148+
setPassword(e.target.value);
149+
}}
122150
disabled={isLoading}
123151
className="mt-1 w-full h-10 rounded-md bg-black/50 border border-slate-700/60 px-3 text-sm text-slate-100 outline-none focus:border-emerald-500/60"
124152
placeholder="Password"
@@ -192,9 +220,7 @@ export const HevyLoginModal: React.FC<HevyLoginModalProps> = ({
192220
</div>
193221
</form>
194222

195-
<div className="mt-4 text-[11px] text-slate-400">
196-
You’re logging in via Hevy. Hevy receives your credentials — LiftShift does not store them.
197-
</div>
223+
198224

199225
{showLoginHelp ? (
200226
<div className="mt-4 space-y-3">

frontend/components/LyfataLoginModal.tsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
22
import { motion } from 'motion/react';
33
import { ArrowLeft, ArrowRight, HelpCircle, Key, RefreshCw, Trash2, Upload } from 'lucide-react';
44
import { UNIFORM_HEADER_BUTTON_CLASS, UNIFORM_HEADER_ICON_BUTTON_CLASS } from '../utils/ui/uiConstants';
5+
import { getLyfataApiKey } from '../utils/storage/dataSourceStorage';
56

67
type Intent = 'initial' | 'update';
78

@@ -32,13 +33,7 @@ export const LyfataLoginModal: React.FC<LyfataLoginModalProps> = ({
3233
onBack,
3334
onClose,
3435
}) => {
35-
// Try to load API key from localStorage on mount
36-
const [apiKey, setApiKey] = useState(() => {
37-
if (typeof window !== 'undefined') {
38-
return localStorage.getItem('lyfta_api_key') || '';
39-
}
40-
return '';
41-
});
36+
const [apiKey, setApiKey] = useState(() => getLyfataApiKey() || '');
4237
const [showLoginHelp, setShowLoginHelp] = useState(false);
4338

4439
return (
@@ -91,10 +86,6 @@ export const LyfataLoginModal: React.FC<LyfataLoginModalProps> = ({
9186
onSubmit={(e) => {
9287
e.preventDefault();
9388
const trimmedKey = apiKey.trim();
94-
// Save API key to localStorage
95-
if (typeof window !== 'undefined') {
96-
localStorage.setItem('lyfta_api_key', trimmedKey);
97-
}
9889
onLogin(trimmedKey);
9990
}}
10091
>
@@ -188,9 +179,7 @@ export const LyfataLoginModal: React.FC<LyfataLoginModalProps> = ({
188179
</div>
189180
</form>
190181

191-
<div className="mt-4 text-[11px] text-slate-400">
192-
Your API key is sent only to the backend for syncing. It is stored locally and never shared.
193-
</div>
182+
194183

195184
{showLoginHelp ? (
196185
<motion.div
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { clearEncryptedCredential, getEncryptedCredential, saveEncryptedCredential } from './secureCredentialStorage';
2+
3+
const HEVY_USERNAME_KEY = 'hevy_username_or_email';
4+
const HEVY_PASSWORD_KEY = 'hevy_password';
5+
6+
export const saveHevyUsernameOrEmail = (value: string): void => {
7+
try {
8+
localStorage.setItem(HEVY_USERNAME_KEY, value);
9+
} catch {
10+
}
11+
};
12+
13+
export const getHevyUsernameOrEmail = (): string | null => {
14+
try {
15+
return localStorage.getItem(HEVY_USERNAME_KEY);
16+
} catch {
17+
return null;
18+
}
19+
};
20+
21+
export const clearHevyUsernameOrEmail = (): void => {
22+
try {
23+
localStorage.removeItem(HEVY_USERNAME_KEY);
24+
} catch {
25+
}
26+
};
27+
28+
export const saveHevyPassword = async (password: string): Promise<void> => {
29+
await saveEncryptedCredential(HEVY_PASSWORD_KEY, password);
30+
};
31+
32+
export const getHevyPassword = async (): Promise<string | null> => {
33+
return await getEncryptedCredential(HEVY_PASSWORD_KEY);
34+
};
35+
36+
export const clearHevyPassword = (): void => {
37+
clearEncryptedCredential(HEVY_PASSWORD_KEY);
38+
};
39+
40+
export const clearHevyCredentials = (): void => {
41+
clearHevyUsernameOrEmail();
42+
clearHevyPassword();
43+
};

0 commit comments

Comments
 (0)