From 515bef4fc93ee3862f1f29f1e74371fc2853c2cc Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:42:04 -0400 Subject: [PATCH] feat: password unlock screen on refresh instead of full logout On page reload, tokens persist but crypto keys are lost (by design). Now shows a password-only unlock screen instead of the full login form, so users don't need to re-enter their email after a refresh. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/store.ts | 14 +++--- frontend/src/pages/LoginPage.tsx | 67 ++++++++++++++++++++--------- frontend/src/pages/RegisterPage.tsx | 2 +- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index 14a86b0..937a05b 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -6,12 +6,13 @@ interface AuthState { refreshToken: string | null; userId: string | null; username: string | null; + email: string | null; isAdmin: boolean; // Crypto keys — in-memory only, never persisted masterKey: CryptoKey | null; - privateKey: CryptoKey | null; // X25519 private key (non-extractable CryptoKey) - publicKey: string | null; // X25519 public key (base64) - setAuth: (data: { accessToken: string; refreshToken: string; userId: string; isAdmin?: boolean }) => void; + privateKey: CryptoKey | null; + publicKey: string | null; + setAuth: (data: { accessToken: string; refreshToken: string; userId: string; isAdmin?: boolean; email?: string }) => void; setTokens: (access: string, refresh: string) => void; setMasterKey: (key: CryptoKey) => void; setKeyPair: (publicKeyB64: string, privateKey: CryptoKey) => void; @@ -26,12 +27,13 @@ export const useAuthStore = create()( refreshToken: null, userId: null, username: null, + email: null, isAdmin: false, masterKey: null, privateKey: null as CryptoKey | null, publicKey: null, - setAuth: ({ accessToken, refreshToken, userId, isAdmin }) => - set({ accessToken, refreshToken, userId, isAdmin: isAdmin ?? false }), + setAuth: ({ accessToken, refreshToken, userId, isAdmin, email }) => + set({ accessToken, refreshToken, userId, isAdmin: isAdmin ?? false, ...(email ? { email } : {}) }), setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }), setMasterKey: (masterKey) => set({ masterKey }), setKeyPair: (publicKey, privateKey) => set({ publicKey, privateKey }), @@ -41,6 +43,7 @@ export const useAuthStore = create()( accessToken: null, refreshToken: null, userId: null, + email: null, masterKey: null, privateKey: null, publicKey: null, @@ -54,6 +57,7 @@ export const useAuthStore = create()( refreshToken: s.refreshToken, userId: s.userId, username: s.username, + email: s.email, isAdmin: s.isAdmin, }), } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 1d81911..7ad5d30 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,6 +1,6 @@ import { useState, FormEvent } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { Shield } from 'lucide-react'; +import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { Shield, Lock } from 'lucide-react'; import api from '../lib/api'; import { useAuthStore } from '../lib/store'; import { deriveKeyFromPassword, decryptMasterKey, decryptPrivateKeyToCryptoKey } from '../crypto'; @@ -8,7 +8,12 @@ import { socket } from '../lib/ws'; export default function LoginPage() { const navigate = useNavigate(); - const { setAuth, setMasterKey, setKeyPair, setUsername } = useAuthStore(); + const location = useLocation(); + const { setAuth, setMasterKey, setKeyPair, setUsername, username, email: storedEmail } = useAuthStore(); + + // Reauth: tokens still valid but crypto keys lost on refresh + const isReauth = location.state?.reauth === true && !!storedEmail; + const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [totpCode, setTotpCode] = useState(''); @@ -23,7 +28,7 @@ export default function LoginPage() { try { const res = await api.post('/auth/login', { - email, + email: isReauth ? storedEmail : email, password, ...(totpCode ? { totpCode } : {}), }); @@ -34,18 +39,15 @@ export default function LoginPage() { return; } - const { accessToken, refreshToken, userId, username, kdfSalt, encryptedMasterKey, isAdmin } = res.data; + const { accessToken, refreshToken, userId, username: uname, kdfSalt, encryptedMasterKey, isAdmin } = res.data; - // Re-derive master key from password — never leaves the browser const derived = await deriveKeyFromPassword(password, kdfSalt); const masterKey = await decryptMasterKey(encryptedMasterKey, derived); - // Set tokens first so the next API call has a Bearer token - setAuth({ accessToken, refreshToken, userId, isAdmin }); + setAuth({ accessToken, refreshToken, userId, isAdmin, email: isReauth ? storedEmail! : email }); setMasterKey(masterKey); - setUsername(username); + setUsername(uname); - // Fetch and decrypt the X25519 private key (stored encrypted on server for cross-device support) const keyRes = await api.get('/auth/my-key'); const privateKey = await decryptPrivateKeyToCryptoKey(keyRes.data.encrypted_private_key, masterKey); setKeyPair(keyRes.data.public_key, privateKey); @@ -63,17 +65,27 @@ export default function LoginPage() {
- + {isReauth ? ( + + ) : ( + + )}

CloudLink

-

End-to-end encrypted

+

+ {isReauth ? `Welcome back, ${username}` : 'End-to-end encrypted'} +

-

Sign in

+

+ {isReauth ? 'Enter your password to unlock' : 'Sign in'} +

{error &&

{error}

} - {!require2fa ? ( + {isReauth ? ( + + ) : !require2fa ? ( <> @@ -95,21 +107,33 @@ export default function LoginPage() { )} + + {isReauth && ( + + )} -

- No account?{' '} - Register -

+ {!isReauth && ( +

+ No account?{' '} + Register +

+ )}
); } -function Field({ label, type, value, onChange }: { - label: string; type: string; value: string; onChange: (v: string) => void; +function Field({ label, type, value, onChange, autoFocus }: { + label: string; type: string; value: string; onChange: (v: string) => void; autoFocus?: boolean; }) { return (
@@ -119,6 +143,7 @@ function Field({ label, type, value, onChange }: { type={type} value={value} onChange={(e) => onChange(e.target.value)} + autoFocus={autoFocus} required />
diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index 6ba1196..6c50c49 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -52,7 +52,7 @@ export default function RegisterPage() { }); const { accessToken, refreshToken, userId } = res.data; - setAuth({ accessToken, refreshToken, userId }); + setAuth({ accessToken, refreshToken, userId, email }); setMasterKey(masterKey); setKeyPair(publicKeyB64, privateKey); setUsername(username);