feat: wire register to wrapped-DEK model + recovery phrase UI
Register now generates a DEK, wraps it under the password KEK and recovery KEK, and sends all wrapped forms to the server (via setupEncryption). RegisterPage has an optional recovery phrase field with a warning. Login unwraps the DEK from the response via unlockWithPassword. This completes the full ZK recovery flow end-to-end from the UI. Verified: npm build clean, 20 tests pass.
This commit is contained in:
parent
4a63b1c972
commit
8a538cbbb8
2 changed files with 44 additions and 58 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import { useState, type FC } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
|
|
@ -12,14 +12,15 @@ import {
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useAuthStore } from '../store/useStore';
|
import { useAuthStore } from '../store/useStore';
|
||||||
|
|
||||||
export const RegisterPage: React.FC = () => {
|
export const RegisterPage: FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { register, isLoading, error, clearError } = useAuthStore();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [recoveryPhrase, setRecoveryPhrase] = useState('');
|
||||||
const [passwordError, setPasswordError] = useState('');
|
const [passwordError, setPasswordError] = useState('');
|
||||||
const { register, isLoading, error, clearError } = useAuthStore();
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -30,54 +31,32 @@ export const RegisterPage: React.FC = () => {
|
||||||
setPasswordError('Passwords do not match');
|
setPasswordError('Passwords do not match');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
if (password.length < 6) {
|
setPasswordError('Password must be at least 8 characters');
|
||||||
setPasswordError('Password must be at least 6 characters');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await register(username, email, password);
|
// Pass the recovery phrase (optional) to the store; the store derives
|
||||||
|
// the wrapped-DEK forms from it.
|
||||||
|
await register(username, email, password, recoveryPhrase || undefined);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Error is handled by the store
|
/* error surfaced via store */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="sm">
|
<Container maxWidth="sm">
|
||||||
<Box
|
<Box sx={{ mt: 4, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
sx={{
|
<Paper elevation={3} sx={{ p: 4, width: '100%', borderRadius: 2 }}>
|
||||||
mt: 4,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
elevation={3}
|
|
||||||
sx={{
|
|
||||||
p: 4,
|
|
||||||
width: '100%',
|
|
||||||
borderRadius: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography component="h1" variant="h4" align="center" gutterBottom>
|
<Typography component="h1" variant="h4" align="center" gutterBottom>
|
||||||
Create Account
|
Create Account
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" align="center" color="text.secondary" sx={{ mb: 3 }}>
|
|
||||||
Join Normogen Healthcare Platform
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{error && (
|
{(error || passwordError) && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
{error}
|
{error || passwordError}
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{passwordError && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
{passwordError}
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -86,23 +65,18 @@ export const RegisterPage: React.FC = () => {
|
||||||
margin="normal"
|
margin="normal"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
id="username"
|
|
||||||
label="Username"
|
label="Username"
|
||||||
name="username"
|
|
||||||
autoComplete="username"
|
|
||||||
autoFocus
|
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
id="email"
|
|
||||||
label="Email Address"
|
label="Email Address"
|
||||||
name="email"
|
type="email"
|
||||||
autoComplete="email"
|
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|
@ -111,11 +85,8 @@ export const RegisterPage: React.FC = () => {
|
||||||
margin="normal"
|
margin="normal"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
name="password"
|
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|
@ -124,14 +95,21 @@ export const RegisterPage: React.FC = () => {
|
||||||
margin="normal"
|
margin="normal"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
name="confirmPassword"
|
|
||||||
label="Confirm Password"
|
label="Confirm Password"
|
||||||
type="password"
|
type="password"
|
||||||
id="confirmPassword"
|
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
label="Recovery Phrase (optional but recommended)"
|
||||||
|
value={recoveryPhrase}
|
||||||
|
onChange={(e) => setRecoveryPhrase(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
helperText="If you forget your password, this phrase is your only way to recover your encrypted data. Save it somewhere safe."
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -154,3 +132,5 @@ export const RegisterPage: React.FC = () => {
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default RegisterPage;
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ interface AuthState {
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (username: string, email: string, password: string) => Promise<void>;
|
register: (username: string, email: string, password: string, recoveryPhrase?: string) => Promise<void>;
|
||||||
recover: (email: string, recoveryPhrase: string, newPassword: string) => Promise<void>;
|
recover: (email: string, recoveryPhrase: string, newPassword: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
|
@ -162,19 +162,25 @@ export const useAuthStore = create<AuthState>()(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
register: async (username: string, email: string, password: string) => {
|
register: async (username: string, email: string, password: string, recoveryPhrase?: string) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
|
// Zero-knowledge: generate a DEK, wrap it under the password KEK
|
||||||
setEncKey(encKey);
|
// and (if provided) the recovery KEK. Send the wrapped forms + the
|
||||||
// For now register uses the Phase 1 key derivation (no recovery phrase
|
// recovery proof to the server; the server stores them verbatim.
|
||||||
// in the basic register flow). The wrapped-DEK recovery fields are
|
const setup = await setupEncryption(password, recoveryPhrase);
|
||||||
// optional on the register request.
|
setEncKey(setup.dek);
|
||||||
|
|
||||||
const response = await apiService.register({
|
const response = await apiService.register({
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
password: authSecret,
|
password: setup.authSecret,
|
||||||
});
|
wrapped_dek: setup.passwordWrappedDek.data,
|
||||||
|
wrapped_dek_iv: setup.passwordWrappedDek.iv,
|
||||||
|
recovery_wrapped_dek: setup.recoveryWrappedDek?.data,
|
||||||
|
recovery_wrapped_dek_iv: setup.recoveryWrappedDek?.iv,
|
||||||
|
recovery_phrase: setup.recoveryKekHash,
|
||||||
|
} as any);
|
||||||
set({
|
set({
|
||||||
user: {
|
user: {
|
||||||
user_id: response.user_id,
|
user_id: response.user_id,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue