Compare commits

...

6 Commits

Author SHA1 Message Date
Sosthene
252bba2cdb Download the backup json as a file 2025-07-16 12:57:55 +02:00
Sosthene
108c27fc48 Add import profile step in login 2025-07-16 12:57:55 +02:00
Sosthene
23a75b68c6 Add create backup button in my account 2025-07-16 12:57:55 +02:00
Sosthene
e4b9b0a56d Add exportBackupFile in MessageBus 2025-07-16 12:57:55 +02:00
Sosthene
9e0f3d3b73 Add StepImportProfile component 2025-07-16 12:57:55 +02:00
Sosthene
97e86308ce [bug] make MessageBus more reliable 2025-07-16 12:56:02 +02:00
8 changed files with 386 additions and 39 deletions

View File

@ -4,7 +4,7 @@ import Typography, { ETypo, ETypoColor } from "@Front/Components/DesignSystem/Ty
//import Image from "next/image";
import Form from "@Front/Components/DesignSystem/Form";
import TextField from "@Front/Components/DesignSystem/Form/TextField";
import Button from "@Front/Components/DesignSystem/Button";
import Button, { EButtonVariant, EButtonstyletype } from "@Front/Components/DesignSystem/Button";
//import franceConnectLogo from "../france-connect.svg";
// import { useRouter } from "next/router";
// import Customers from "@Front/Api/Auth/Id360/Customers/Customers";
@ -20,10 +20,11 @@ import Confirm from "@Front/Components/DesignSystem/OldModal/Confirm";
type IProps = {
onSubmit: (e: React.FormEvent<HTMLFormElement> | null, values: { [key: string]: string }) => void;
validationErrors: ValidationError[];
onImportProfile?: () => void;
};
export default function StepEmail(props: IProps) {
const { onSubmit, validationErrors } = props;
const { onSubmit, validationErrors, onImportProfile } = props;
const [isErrorModalOpen, setIsErrorModalOpen] = useState(0);
/* const router = useRouter();
@ -111,6 +112,20 @@ export default function StepEmail(props: IProps) {
<Button type="submit">Se connecter</Button>
</Form>
</div>
{onImportProfile && (
<div className={classes["section"]}>
<Typography typo={ETypo.TITLE_H6} color={ETypoColor.TEXT_ACCENT} className={classes["section-title"]}>
Options avancées :
</Typography>
<Button
variant={EButtonVariant.SECONDARY}
styletype={EButtonstyletype.TEXT}
onClick={onImportProfile}
>
Importer un profil depuis un fichier
</Button>
</div>
)}
</div>
<Confirm
isOpen={isErrorModalOpen === 1}

View File

@ -0,0 +1,72 @@
@import "@Themes/constants.scss";
.root {
display: flex;
flex-direction: column;
max-width: 530px;
margin: auto;
margin-top: 220px;
.title {
text-align: left;
@media (max-width: $screen-s) {
font-family: 48px;
}
}
.description {
margin-top: 16px;
color: var(--color-neutral-700);
line-height: 1.5;
}
.import-section {
margin-top: 32px;
}
.error {
margin-top: 16px;
padding: 12px;
background-color: var(--color-error-50);
border: 1px solid var(--color-error-200);
border-radius: 6px;
}
.profile-preview {
margin-top: 24px;
padding: 16px;
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
.preview-title {
margin-bottom: 12px;
color: var(--color-neutral-800);
}
}
.actions {
margin-top: 32px;
display: flex;
gap: 16px;
justify-content: flex-end;
.back-button {
min-width: 100px;
}
.submit-button {
min-width: 140px;
}
@media (max-width: $screen-s) {
flex-direction: column;
.back-button,
.submit-button {
width: 100%;
}
}
}
}

View File

@ -0,0 +1,132 @@
import React, { useState, useCallback } from "react";
import classes from "./classes.module.scss";
import Typography, { ETypo, ETypoColor } from "@Front/Components/DesignSystem/Typography";
import Button, { EButtonVariant } from "@Front/Components/DesignSystem/Button";
import DragAndDrop from "@Front/Components/DesignSystem/DragAndDrop";
import { ValidationError } from "class-validator";
type IProps = {
onSubmit: (profileData: any) => void;
onBack: () => void;
validationErrors: ValidationError[];
};
export default function StepImportProfile(props: IProps) {
const { onSubmit, onBack, validationErrors } = props;
const [importedFile, setImportedFile] = useState<File | null>(null);
const [profileData, setProfileData] = useState<any>(null);
const [error, setError] = useState<string>("");
const validateProfileStructure = (data: any): boolean => {
// Basic validation for profile structure
return data &&
typeof data === "object" &&
data.version &&
data.userData &&
typeof data.userData === "object";
};
const handleFileUpload = useCallback(async (files: File[]) => {
const file = files[0];
if (!file) return;
// Validate file type
if (file.type !== "application/json") {
setError("Veuillez sélectionner un fichier JSON valide.");
return;
}
// Validate file size (max 1MB)
if (file.size > 1024 * 1024) {
setError("Le fichier est trop volumineux. Taille maximum : 1MB.");
return;
}
setImportedFile(file);
setError("");
// Read and parse JSON
try {
const text = await file.text();
const data = JSON.parse(text);
// Validate profile structure
if (!validateProfileStructure(data)) {
setError("Le fichier ne contient pas un profil valide.");
return;
}
setProfileData(data);
} catch (err) {
setError("Erreur lors de la lecture du fichier JSON.");
}
}, []);
const handleSubmit = useCallback(() => {
if (profileData) {
onSubmit(profileData);
}
}, [profileData, onSubmit]);
// Get validation error for import
const importError = validationErrors.find((error) => error.property === "import");
return (
<div className={classes["root"]}>
<Typography typo={ETypo.DISPLAY_LARGE}>
<div className={classes["title"]}>Importer un profil</div>
</Typography>
<Typography typo={ETypo.TEXT_MD_REGULAR} className={classes["description"]}>
Sélectionnez un fichier JSON contenant vos données de profil pour restaurer votre session.
</Typography>
<div className={classes["import-section"]}>
<DragAndDrop
title="Importer un profil"
description="Glissez-déposez votre fichier JSON ou cliquez pour parcourir"
onChange={handleFileUpload}
/>
</div>
{(error || importError) && (
<Typography typo={ETypo.TEXT_SM_REGULAR} color={ETypoColor.COLOR_ERROR_500} className={classes["error"]}>
{error || (importError?.constraints && Object.values(importError.constraints)[0])}
</Typography>
)}
{profileData && (
<div className={classes["profile-preview"]}>
<Typography typo={ETypo.TEXT_LG_SEMIBOLD} className={classes["preview-title"]}>
Profil détecté :
</Typography>
<Typography typo={ETypo.TEXT_MD_REGULAR}>
{profileData.userData?.email || "Email non disponible"}
</Typography>
<Typography typo={ETypo.TEXT_SM_REGULAR} color={ETypoColor.COLOR_NEUTRAL_600}>
Version : {profileData.version}
</Typography>
{profileData.exportedAt && (
<Typography typo={ETypo.TEXT_SM_REGULAR} color={ETypoColor.COLOR_NEUTRAL_600}>
Exporté le : {new Date(profileData.exportedAt).toLocaleDateString('fr-FR')}
</Typography>
)}
</div>
)}
<div className={classes["actions"]}>
<Button variant={EButtonVariant.SECONDARY} onClick={onBack} className={classes["back-button"]}>
Retour
</Button>
<Button
variant={EButtonVariant.PRIMARY}
onClick={handleSubmit}
disabled={!profileData}
className={classes["submit-button"]}
>
Importer le profil
</Button>
</div>
</div>
);
}

View File

@ -21,6 +21,7 @@ import UserStore from "@Front/Stores/UserStore";
import AuthModal from "src/sdk/AuthModal";
import CustomerService from "src/common/Api/LeCoffreApi/sdk/CustomerService";
import StepImportProfile from "./StepImportProfile";
export enum LoginStep {
EMAIL,
@ -28,6 +29,7 @@ export enum LoginStep {
PASSWORD,
NEW_PASSWORD,
PASSWORD_FORGOTTEN,
IMPORT_PROFILE,
}
export default function Login() {
@ -43,6 +45,7 @@ export default function Login() {
const [partialPhoneNumber, setPartialPhoneNumber] = useState<string>("");
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// const openErrorModal = useCallback(() => {
// setIsErrorModalOpen(true);
@ -241,10 +244,55 @@ export default function Login() {
}
}, [email, totpCodeUid]);
const onImportProfileSubmit = useCallback(async (profileData: any) => {
try {
setIsLoading(true);
setValidationErrors([]);
// Call API to validate and import profile
// Note: You'll need to implement this method in your Auth service
// const response = await Auth.getInstance().importProfile(profileData);
// For now, we'll simulate a successful import
// In a real implementation, you would:
// 1. Send the profile data to your backend
// 2. Validate the profile on the server
// 3. Return authentication tokens
// 4. Connect the user
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// For demo purposes, we'll just redirect to the dashboard
// In reality, you'd use the response from the API
// CustomerStore.instance.connect(response.accessToken, response.refreshToken);
// Redirect to dashboard
router.push(Module.getInstance().get().modules.pages.Folder.pages.Select.props.path);
} catch (error: any) {
setValidationErrors([
{
property: "import",
constraints: {
[error.http_status || "500"]: error.message || "Erreur lors de l'import du profil",
},
},
]);
} finally {
setIsLoading(false);
}
}, [router]);
return (
<DefaultDoubleSidePage title={"Login"} image={backgroundImage}>
<div className={classes["root"]}>
{step === LoginStep.EMAIL && <StepEmail onSubmit={onEmailFormSubmit} validationErrors={validationErrors} />}
{step === LoginStep.EMAIL && (
<StepEmail
onSubmit={onEmailFormSubmit}
validationErrors={validationErrors}
onImportProfile={() => setStep(LoginStep.IMPORT_PROFILE)}
/>
)}
{step === LoginStep.TOTP && (
<StepTotp
onSubmit={onSmsCodeSubmit}
@ -281,6 +329,13 @@ export default function Login() {
});
}}
/>}
{step === LoginStep.IMPORT_PROFILE && (
<StepImportProfile
onSubmit={onImportProfileSubmit}
onBack={() => setStep(LoginStep.EMAIL)}
validationErrors={validationErrors}
/>
)}
</div>
{/* <Confirm
isOpen={isErrorModalOpen}

View File

@ -1,5 +1,6 @@
import backgroundImage from "@Assets/images/background_refonte.svg";
import Users from "@Front/Api/LeCoffreApi/Notary/Users/Users";
import Button, { EButtonVariant, EButtonSize, EButtonstyletype } from "@Front/Components/DesignSystem/Button";
import Form from "@Front/Components/DesignSystem/Form";
import TextField from "@Front/Components/DesignSystem/Form/TextField";
import Typography, { ETypo, ETypoColor } from "@Front/Components/DesignSystem/Typography";
@ -9,6 +10,7 @@ import User from "le-coffre-resources/dist/Notary";
import React, { useEffect } from "react";
import classes from "./classes.module.scss";
import MessageBus from "src/sdk/MessageBus";
export default function MyAccount() {
const [user, setUser] = React.useState<User | null>(null);
@ -33,6 +35,42 @@ export default function MyAccount() {
});
}, []);
const downloadBackupFile = (backupFile: string, filename: string = 'backup.json') => {
try {
// Create a blob with the backup data
const blob = new Blob([backupFile], { type: 'application/json' });
// Create a download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
// Trigger the download
document.body.appendChild(link);
link.click();
// Clean up
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading backup file:', error);
}
};
const handleCreateRestorationFile = () => {
MessageBus.getInstance().exportBackupFile().then((backupFile) => {
// Generate filename with current date
const now = new Date();
const dateString = now.toISOString().split('T')[0]; // YYYY-MM-DD format
const filename = `backup_${dateString}.json`;
downloadBackupFile(backupFile, filename);
}).catch((error) => {
console.error(error);
});
};
return (
<DefaultDoubleSidePage title={"Mon Compte"} image={backgroundImage} showHeader={true}>
<div className={classes["root"]}>
@ -104,6 +142,16 @@ export default function MyAccount() {
canCopy
/>
</Form>
<Button
variant={EButtonVariant.PRIMARY}
size={EButtonSize.LG}
styletype={EButtonstyletype.CONTAINED}
fullwidth={true}
onClick={handleCreateRestorationFile}
>
Créer un fichier de restauration
</Button>
</div>
</DefaultDoubleSidePage>
);

View File

@ -12,9 +12,6 @@ function Iframe({ showIframe = false }: IframeProps) {
if (iframeRef.current) {
IframeReference.setIframe(iframeRef.current);
}
return () => {
IframeReference.setIframe(null);
};
}, [iframeRef.current]);
return (

View File

@ -5,6 +5,11 @@ export default class IframeReference {
private constructor() { }
public static setTargetOrigin(targetOrigin: string): void {
if (this.targetOrigin) {
console.debug("targetOrigin is already set");
return;
}
try {
new URL(targetOrigin);
this.targetOrigin = targetOrigin;

View File

@ -644,6 +644,39 @@ export default class MessageBus {
});
}
public exportBackupFile(): Promise<any> {
return new Promise<any>((resolve: (backupFile: any) => void, reject: (error: string) => void) => {
this.checkToken().then(() => {
const messageId = `EXPORT_BACKUP_${uuidv4()}`;
const unsubscribe = EventBus.getInstance().on('BACKUP_RETRIEVED', (responseId: string, backupFile: string) => {
if (responseId !== messageId) {
return;
}
unsubscribe();
resolve(backupFile);
});
const unsubscribeError = EventBus.getInstance().on('ERROR_BACKUP_RETRIEVED', (responseId: string, error: string) => {
if (responseId !== messageId) {
return;
}
unsubscribeError();
reject(error);
});
const user = User.getInstance();
const accessToken = user.getAccessToken()!;
this.sendMessage({
type: 'EXPORT_BACKUP',
accessToken,
messageId
});
}).catch(reject);
});
}
public verifyMerkleProof(merkleProof: string, documentHash: string): Promise<boolean> {
return new Promise<boolean>((resolve: (isValid: boolean) => void, reject: (error: string) => void) => {
this.checkToken().then(() => {
@ -759,17 +792,15 @@ export default class MessageBus {
}
private sendMessage(message: any): void {
const targetOrigin = IframeReference.getTargetOrigin();
if (!targetOrigin) {
console.error('[MessageBus] sendMessage: targetOrigin not found');
return;
}
const iframe = IframeReference.getIframe();
if (!iframe) {
console.error('[MessageBus] sendMessage: iframe not found');
return;
}
iframe.contentWindow?.postMessage(message, targetOrigin);
this.isReady().then(() => {
try {
const targetOrigin = IframeReference.getTargetOrigin();
const iframe = IframeReference.getIframe();
iframe.contentWindow?.postMessage(message, targetOrigin);
} catch (error) {
console.error('[MessageBus] sendMessage: error', error);
}
}).catch(console.error);
}
private handleMessage(event: MessageEvent): void {
@ -777,30 +808,18 @@ export default class MessageBus {
return;
}
const iframe = IframeReference.getIframe();
if (!iframe) {
console.error('[MessageBus] handleMessage: iframe not found');
return;
}
try {
const targetOrigin = IframeReference.getTargetOrigin();
if (event.source !== iframe.contentWindow) {
console.error('[MessageBus] handleMessage: source not match');
return;
}
if (event.origin !== targetOrigin) {
throw new Error(`origin don't match: expected ${targetOrigin}, got ${event.origin} with type ${event.data.type}`);
}
const targetOrigin = IframeReference.getTargetOrigin();
if (!targetOrigin) {
console.error('[MessageBus] handleMessage: targetOrigin not found');
return;
}
if (event.origin !== targetOrigin) {
console.error('[MessageBus] handleMessage: origin not match');
return;
}
if (!event.data || typeof event.data !== 'object') {
console.error('[MessageBus] handleMessage: data not found');
if (!event.data || typeof event.data !== 'object') {
throw new Error('data not found');
}
} catch (error) {
console.error('[MessageBus] handleMessage:', error);
return;
}
@ -877,6 +896,10 @@ export default class MessageBus {
this.doHandleMessage(message.messageId, 'MERKLE_PROOF_VALIDATED', message, (message: any) => message.isValid);
break;
case 'BACKUP_RETRIEVED': // EXPORT_BACKUP
this.doHandleMessage(message.messageId, 'BACKUP_RETRIEVED', message, (message: any) => message.backupFile);
break;
case 'ERROR':
console.error('Error:', message);
this.errors[message.messageId] = message.error;