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 {
|
||||
Container,
|
||||
|
|
@ -12,14 +12,15 @@ import {
|
|||
} from '@mui/material';
|
||||
import { useAuthStore } from '../store/useStore';
|
||||
|
||||
export const RegisterPage: React.FC = () => {
|
||||
export const RegisterPage: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { register, isLoading, error, clearError } = useAuthStore();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [recoveryPhrase, setRecoveryPhrase] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const { register, isLoading, error, clearError } = useAuthStore();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -30,54 +31,32 @@ export const RegisterPage: React.FC = () => {
|
|||
setPasswordError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setPasswordError('Password must be at least 6 characters');
|
||||
if (password.length < 8) {
|
||||
setPasswordError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
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('/');
|
||||
} catch (err) {
|
||||
// Error is handled by the store
|
||||
} catch {
|
||||
/* error surfaced via store */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
mt: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
width: '100%',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ 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>
|
||||
Create Account
|
||||
</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 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{passwordError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{passwordError}
|
||||
{error || passwordError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
|
@ -86,23 +65,18 @@ export const RegisterPage: React.FC = () => {
|
|||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="username"
|
||||
label="Username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="Email Address"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
|
|
@ -111,11 +85,8 @@ export const RegisterPage: React.FC = () => {
|
|||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
|
|
@ -124,14 +95,21 @@ export const RegisterPage: React.FC = () => {
|
|||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="confirmPassword"
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
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
|
||||
type="submit"
|
||||
fullWidth
|
||||
|
|
@ -154,3 +132,5 @@ export const RegisterPage: React.FC = () => {
|
|||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ interface AuthState {
|
|||
|
||||
// Actions
|
||||
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>;
|
||||
logout: () => Promise<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 });
|
||||
try {
|
||||
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
|
||||
setEncKey(encKey);
|
||||
// For now register uses the Phase 1 key derivation (no recovery phrase
|
||||
// in the basic register flow). The wrapped-DEK recovery fields are
|
||||
// optional on the register request.
|
||||
// Zero-knowledge: generate a DEK, wrap it under the password KEK
|
||||
// and (if provided) the recovery KEK. Send the wrapped forms + the
|
||||
// recovery proof to the server; the server stores them verbatim.
|
||||
const setup = await setupEncryption(password, recoveryPhrase);
|
||||
setEncKey(setup.dek);
|
||||
|
||||
const response = await apiService.register({
|
||||
username,
|
||||
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({
|
||||
user: {
|
||||
user_id: response.user_id,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue