diff --git a/workstation-setup/src/Main.qml b/workstation-setup/src/Main.qml new file mode 100644 index 0000000..42a7b5c --- /dev/null +++ b/workstation-setup/src/Main.qml @@ -0,0 +1,1331 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Layouts +import QtQuick.Controls as Controls +import org.kde.kirigami as Kirigami + +Kirigami.ApplicationWindow { + id: root + + title: "Configuration initiale du poste" + visibility: Window.FullScreen + color: "#eef2f7" + + property int currentStep: 0 + property var steps: [ + { "title": "Bienvenue", "subtitle": "Présentation" }, + { "title": "Mot de passe", "subtitle": "Accès de secours" }, + { "title": "YubiKey", "subtitle": "PIN personnel" }, + { "title": "Terminé", "subtitle": "Poste prêt" } + ] + + property bool passwordStepValid: + currentPassword.text.trim().length > 0 && + newPassword.text.length > 0 && + confirmPassword.text.length > 0 && + newPassword.text === confirmPassword.text + + property bool pinStepValid: + currentPin.text.length >= 4 && + newPin.text.length >= 4 && + confirmPin.text.length >= 4 && + newPin.text === confirmPin.text + + // En production, l'état réel vient du backend et des marqueurs locaux. + // La simple saisie des champs ne signifie jamais que l'opération a réussi. + property bool workflowComplete: provisioning.complete + property bool closeAttempted: false + property string operationError: "" + + function goNext() { + if (currentStep === 0) + currentStep = 1 + } + + function goBack() { + // Une étape déjà appliquée au système n'est jamais rejouée. + if (currentStep === 1 && !provisioning.passwordDone) + currentStep = 0 + } + + Component.onCompleted: { + if (provisioning.complete) + currentStep = 3 + else if (provisioning.passwordDone) + currentStep = 2 + else + currentStep = 0 + } + + Connections { + target: provisioning + + function onPasswordChangeFinished(success, message) { + root.operationError = success ? "" : message + if (success) + root.currentStep = 2 + } + + function onPinChangeFinished(success, message) { + root.operationError = success ? "" : message + if (success) + root.currentStep = 3 + } + } + + onClosing: function(close) { + if (!workflowComplete) { + close.accepted = false + closeAttempted = true + closeWarningTimer.restart() + } + } + + onVisibilityChanged: { + if (!workflowComplete && visibility !== Window.FullScreen) + visibility = Window.FullScreen + } + + Timer { + id: closeWarningTimer + interval: 3500 + repeat: false + onTriggered: root.closeAttempted = false + } + + // Indicateur volontairement informatif : il ne bloque jamais + // la validation du mot de passe. La longueur est privilégiée, + // la diversité des caractères apporte un bonus visuel. + function passwordStrengthLevel(password) { + if (password.length === 0) + return 0 + + var classes = 0 + if (/[a-z]/.test(password)) classes += 1 + if (/[A-Z]/.test(password)) classes += 1 + if (/[0-9]/.test(password)) classes += 1 + if (/[^A-Za-z0-9]/.test(password)) classes += 1 + + var level = 1 + + if (password.length >= 16) + level = classes >= 2 ? 4 : 3 + else if (password.length >= 12) + level = classes >= 2 ? 3 : 2 + else if (password.length >= 8) + level = classes >= 3 ? 2 : 1 + + // Quelques motifs manifestement faibles : on réduit + // l'indication sans empêcher l'utilisateur de continuer. + var lower = password.toLowerCase() + if (lower === "password" || lower === "motdepasse" || + lower === "azerty" || lower === "qwerty" || + lower === "12345678" || /^(.)\1+$/.test(password)) { + level = 1 + } + + return level + } + + function passwordStrengthLabel(level) { + if (level === 1) return "Faible" + if (level === 2) return "Moyen" + if (level === 3) return "Bon" + if (level === 4) return "Très bon" + return "Non évalué" + } + + function passwordStrengthColor(level) { + if (level === 1) return "#dc2626" + if (level === 2) return "#d97706" + if (level === 3) return "#2563eb" + if (level === 4) return "#16a34a" + return "#cbd5e1" + } + + function passwordStrengthHint(level) { + if (level === 1) return "Privilégiez surtout la longueur : une phrase de passe de 12 caractères ou plus est un bon point de départ." + if (level === 2) return "Correct pour un usage courant, mais quelques caractères supplémentaires amélioreraient sensiblement la robustesse." + if (level === 3) return "Bonne robustesse apparente : la longueur et la diversité sont satisfaisantes." + if (level === 4) return "Très bonne robustesse apparente : mot de passe long et suffisamment diversifié." + return "Saisissez votre nouveau mot de passe pour obtenir une indication de robustesse." + } + + component PrimaryButton: Controls.Button { + id: control + implicitHeight: 48 + implicitWidth: 170 + leftPadding: 24 + rightPadding: 24 + + contentItem: Controls.Label { + text: control.text + font.pixelSize: 15 + font.weight: Font.DemiBold + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 12 + color: control.down ? "#1e40af" : control.hovered ? "#2563eb" : "#1d4ed8" + Behavior on color { ColorAnimation { duration: 120 } } + } + } + + component SecondaryButton: Controls.Button { + id: control + implicitHeight: 48 + implicitWidth: 140 + leftPadding: 22 + rightPadding: 22 + + contentItem: Controls.Label { + text: control.text + font.pixelSize: 15 + font.weight: Font.Medium + color: "#334155" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 12 + color: control.down ? "#dbe3ee" : control.hovered ? "#edf2f7" : "transparent" + border.width: 1 + border.color: "#cbd5e1" + } + } + + component SecretField: Controls.TextField { + id: field + + property bool secretVisible: false + + Layout.fillWidth: true + implicitHeight: 50 + echoMode: secretVisible ? TextInput.Normal : TextInput.Password + passwordCharacter: "●" + font.pixelSize: 15 + leftPadding: 16 + rightPadding: 56 + selectByMouse: true + + background: Rectangle { + radius: 10 + color: field.activeFocus ? "#ffffff" : "#f8fafc" + border.width: field.activeFocus ? 2 : 1 + border.color: field.activeFocus ? "#2563eb" : "#cbd5e1" + } + + Controls.ToolButton { + id: visibilityButton + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + width: 38 + height: 38 + + hoverEnabled: true + focusPolicy: Qt.NoFocus + Accessible.name: field.secretVisible ? "Masquer la valeur" : "Afficher la valeur" + + onClicked: field.secretVisible = !field.secretVisible + + contentItem: Kirigami.Icon { + source: field.secretVisible ? "view-hidden" : "view-visible" + implicitWidth: 20 + implicitHeight: 20 + color: visibilityButton.hovered ? "#2563eb" : "#64748b" + } + + background: Rectangle { + radius: 8 + color: visibilityButton.pressed ? "#dbeafe" + : visibilityButton.hovered ? "#eff6ff" + : "transparent" + + Behavior on color { + ColorAnimation { duration: 100 } + } + } + } + } + + component FieldLabel: RowLayout { + id: fieldLabel + + property string iconName: "dialog-password" + property string labelText: "" + + spacing: 8 + + Kirigami.Icon { + source: fieldLabel.iconName + implicitWidth: 17 + implicitHeight: 17 + color: "#64748b" + } + + Controls.Label { + text: fieldLabel.labelText + color: "#334155" + font.pixelSize: 13 + font.weight: Font.DemiBold + } + } + + component StepDot: Rectangle { + required property int stepIndex + + width: 34 + height: 34 + radius: 17 + color: stepIndex < root.currentStep ? "#22c55e" + : stepIndex === root.currentStep ? "#ffffff" + : "#17345f" + border.width: stepIndex === root.currentStep ? 2 : 0 + border.color: "#93c5fd" + + Controls.Label { + anchors.centerIn: parent + text: parent.stepIndex < root.currentStep ? "✓" : (parent.stepIndex + 1) + color: parent.stepIndex === root.currentStep ? "#0f2b50" : "white" + font.pixelSize: 14 + font.weight: Font.Bold + } + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // ----------------------------------------------------- + // Colonne gauche : identité + progression + // ----------------------------------------------------- + Rectangle { + Layout.preferredWidth: Math.max(330, root.width * 0.29) + Layout.fillHeight: true + + gradient: Gradient { + GradientStop { position: 0.0; color: "#081a33" } + GradientStop { position: 0.55; color: "#0b2a50" } + GradientStop { position: 1.0; color: "#123d70" } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 44 + spacing: 0 + + RowLayout { + spacing: 14 + + Rectangle { + width: 54 + height: 54 + radius: 16 + color: "#1d4ed8" + + Controls.Label { + anchors.centerIn: parent + text: "N" + color: "white" + font.pixelSize: 25 + font.weight: Font.Bold + } + } + + ColumnLayout { + spacing: 2 + + Controls.Label { + text: "NixOS Workstations" + color: "white" + font.pixelSize: 19 + font.weight: Font.DemiBold + } + + Controls.Label { + text: "Configuration du poste" + color: "#a8c1e1" + font.pixelSize: 13 + } + } + } + + Item { Layout.preferredHeight: 70 } + + Controls.Label { + text: "VOTRE CONFIGURATION" + color: "#7fa4d1" + font.pixelSize: 11 + font.weight: Font.Bold + font.letterSpacing: 1.5 + } + + Item { Layout.preferredHeight: 26 } + + Repeater { + model: root.steps + + delegate: Item { + required property int index + required property var modelData + + Layout.fillWidth: true + Layout.preferredHeight: 78 + + RowLayout { + anchors.fill: parent + spacing: 16 + + // Colonne de largeur fixe : tous les marqueurs et tous + // les titres commencent exactement au même endroit. + Item { + Layout.preferredWidth: 42 + Layout.fillHeight: true + + StepDot { + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + stepIndex: index + } + + Rectangle { + visible: index < root.steps.length - 1 + anchors.top: parent.top + anchors.topMargin: 34 + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: 2 + color: index < root.currentStep ? "#22c55e" : "#28517e" + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 3 + + Controls.Label { + Layout.fillWidth: true + text: modelData.title + color: index === root.currentStep ? "white" : "#c1d3e8" + font.pixelSize: 15 + font.weight: index === root.currentStep ? Font.DemiBold : Font.Medium + horizontalAlignment: Text.AlignLeft + } + + Controls.Label { + Layout.fillWidth: true + text: modelData.subtitle + color: "#7699c2" + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + } + } + } + } + } + + Item { Layout.fillHeight: true } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 86 + radius: 14 + color: "#102f55" + border.width: 1 + border.color: "#214b78" + + RowLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Controls.Label { + text: "🛡" + font.pixelSize: 23 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + + Controls.Label { + text: "Configuration locale" + color: "white" + font.pixelSize: 13 + font.weight: Font.DemiBold + } + + Controls.Label { + Layout.fillWidth: true + text: "Les secrets définitifs ne seront pas enregistrés dans Git." + wrapMode: Text.WordWrap + color: "#91add0" + font.pixelSize: 11 + } + } + } + } + + Item { Layout.preferredHeight: 20 } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 6 + + Kirigami.Icon { + source: "lock" + implicitWidth: 14 + implicitHeight: 14 + color: "#6289b6" + } + + Controls.Label { + text: "Assistant obligatoire jusqu'à la fin" + color: "#6289b6" + font.pixelSize: 11 + } + } + } + } + + // ----------------------------------------------------- + // Partie droite + // ----------------------------------------------------- + Rectangle { + id: contentArea + Layout.fillWidth: true + Layout.fillHeight: true + color: "#eef2f7" + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Math.max(48, contentArea.width * 0.07) + anchors.rightMargin: Math.max(48, contentArea.width * 0.07) + anchors.topMargin: 34 + anchors.bottomMargin: 34 + spacing: 20 + + RowLayout { + Layout.fillWidth: true + + Rectangle { + implicitWidth: previewLabel.implicitWidth + 28 + implicitHeight: 32 + radius: 16 + color: provisioning.busy ? "#eff6ff" : "#f0fdf4" + border.width: 1 + border.color: provisioning.busy ? "#bfdbfe" : "#bbf7d0" + + Controls.Label { + id: previewLabel + anchors.centerIn: parent + text: provisioning.busy ? "OPÉRATION SÉCURISÉE EN COURS" : "FINALISATION SÉCURISÉE DU POSTE" + color: provisioning.busy ? "#1d4ed8" : "#166534" + font.pixelSize: 11 + font.weight: Font.Bold + } + } + + Item { Layout.fillWidth: true } + + Rectangle { + implicitWidth: lockStatusRow.implicitWidth + 24 + implicitHeight: 34 + radius: 17 + color: root.closeAttempted ? "#fef2f2" : "#f8fafc" + border.width: 1 + border.color: root.closeAttempted ? "#fecaca" : "#cbd5e1" + + RowLayout { + id: lockStatusRow + anchors.centerIn: parent + spacing: 7 + + Kirigami.Icon { + source: root.workflowComplete ? "lock-open" : "lock" + implicitWidth: 16 + implicitHeight: 16 + color: root.closeAttempted ? "#dc2626" : "#64748b" + } + + Controls.Label { + text: root.closeAttempted + ? "Terminez la configuration avant de quitter" + : root.workflowComplete + ? "Configuration complète" + : "Configuration en cours" + color: root.closeAttempted ? "#b91c1c" : "#475569" + font.pixelSize: 11 + font.weight: Font.DemiBold + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 24 + color: "white" + border.width: 1 + border.color: "#dce3ec" + + ColumnLayout { + anchors.fill: parent + anchors.margins: Math.max(34, Math.min(64, parent.width * 0.055)) + spacing: 24 + + // Petit indicateur de progression supérieur + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Repeater { + model: root.steps.length + + Rectangle { + required property int index + Layout.fillWidth: true + implicitHeight: 5 + radius: 3 + color: index <= root.currentStep ? "#2563eb" : "#e2e8f0" + + Behavior on color { ColorAnimation { duration: 180 } } + } + } + } + + StackLayout { + id: wizard + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: root.currentStep + + // --------------------------------- + // 0 - Bienvenue + // --------------------------------- + Item { + ColumnLayout { + anchors.fill: parent + spacing: 20 + + Item { Layout.fillHeight: true } + + Rectangle { + Layout.preferredWidth: 86 + Layout.preferredHeight: 86 + Layout.alignment: Qt.AlignHCenter + radius: 24 + color: "#eff6ff" + border.width: 1 + border.color: "#bfdbfe" + + Controls.Label { + anchors.centerIn: parent + text: "✦" + color: "#2563eb" + font.pixelSize: 42 + font.weight: Font.Bold + } + } + + Controls.Label { + Layout.fillWidth: true + text: "Bienvenue sur votre nouveau poste" + color: "#0f172a" + font.pixelSize: 32 + font.weight: Font.Bold + horizontalAlignment: Text.AlignHCenter + } + + Controls.Label { + Layout.maximumWidth: 720 + Layout.alignment: Qt.AlignHCenter + text: "Quelques étapes rapides vont personnaliser vos moyens d'authentification et finaliser la préparation de votre environnement de travail." + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 16 + lineHeight: 1.25 + horizontalAlignment: Text.AlignHCenter + } + + Item { Layout.preferredHeight: 12 } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 14 + + Repeater { + model: [ + { "icon": "🔑", "title": "Mot de passe", "text": "Définir votre accès local de secours" }, + { "icon": "🔐", "title": "YubiKey", "text": "Personnaliser votre PIN FIDO2" } + ] + + Rectangle { + required property var modelData + Layout.preferredWidth: 290 + Layout.preferredHeight: 128 + radius: 16 + color: "#f8fafc" + border.width: 1 + border.color: "#e2e8f0" + + RowLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 14 + + Controls.Label { + text: modelData.icon + font.pixelSize: 28 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + + Controls.Label { + text: modelData.title + color: "#0f172a" + font.pixelSize: 15 + font.weight: Font.DemiBold + } + + Controls.Label { + Layout.fillWidth: true + text: modelData.text + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 12 + } + } + } + } + } + } + + Item { Layout.fillHeight: true } + + PrimaryButton { + Layout.alignment: Qt.AlignHCenter + text: "Commencer" + onClicked: root.goNext() + } + } + } + + // --------------------------------- + // 1 - Mot de passe + // --------------------------------- + Item { + ColumnLayout { + anchors.fill: parent + spacing: 18 + + RowLayout { + Layout.fillWidth: true + spacing: 20 + + Rectangle { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + radius: 22 + color: "#eff6ff" + border.width: 1 + border.color: "#bfdbfe" + + Kirigami.Icon { + anchors.centerIn: parent + source: "dialog-password" + implicitWidth: 38 + implicitHeight: 38 + color: "#2563eb" + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + + Controls.Label { + text: "Mot de passe local" + color: "#0f172a" + font.pixelSize: 29 + font.weight: Font.Bold + } + + Controls.Label { + Layout.fillWidth: true + text: "Ce mot de passe reste disponible comme solution de secours si votre YubiKey est momentanément indisponible." + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 15 + lineHeight: 1.2 + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 70 + radius: 12 + color: "#eff6ff" + border.width: 1 + border.color: "#bfdbfe" + + RowLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Kirigami.Icon { + source: "emblem-information" + implicitWidth: 22 + implicitHeight: 22 + color: "#2563eb" + } + + Controls.Label { + Layout.fillWidth: true + text: "Le changement est effectué localement par le mécanisme NixOS prévu à cet effet. Votre nouveau mot de passe n’est ni enregistré dans Git, ni transmis au service informatique." + wrapMode: Text.WordWrap + color: "#1e40af" + font.pixelSize: 13 + } + } + } + + Item { Layout.preferredHeight: 4 } + + FieldLabel { + iconName: "dialog-password" + labelText: "Mot de passe temporaire actuel" + } + + SecretField { + id: currentPassword + placeholderText: "Saisissez le mot de passe temporaire" + } + + FieldLabel { + iconName: "document-edit" + labelText: "Nouveau mot de passe" + } + + SecretField { + id: newPassword + placeholderText: "Choisissez votre mot de passe personnel" + } + + // Indicateur de robustesse purement informatif. + // Il ne participe pas à la condition d'activation + // du bouton Continuer. + Rectangle { + id: strengthCard + Layout.fillWidth: true + implicitHeight: 112 + radius: 12 + color: "#f8fafc" + border.width: 1 + border.color: "#e2e8f0" + + property int strengthLevel: root.passwordStrengthLevel(newPassword.text) + + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Kirigami.Icon { + source: "security-high" + implicitWidth: 17 + implicitHeight: 17 + color: root.passwordStrengthColor(strengthCard.strengthLevel) + } + + Controls.Label { + text: "Robustesse du mot de passe" + color: "#334155" + font.pixelSize: 12 + font.weight: Font.DemiBold + } + + Item { Layout.fillWidth: true } + + Controls.Label { + text: root.passwordStrengthLabel(strengthCard.strengthLevel) + color: root.passwordStrengthColor(strengthCard.strengthLevel) + font.pixelSize: 12 + font.weight: Font.Bold + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Repeater { + model: 4 + + Rectangle { + required property int index + Layout.fillWidth: true + implicitHeight: 7 + radius: 4 + color: index < parent.parent.parent.strengthLevel + ? root.passwordStrengthColor(strengthCard.strengthLevel) + : "#e2e8f0" + + Behavior on color { + ColorAnimation { duration: 140 } + } + } + } + } + + Controls.Label { + Layout.fillWidth: true + text: root.passwordStrengthHint(strengthCard.strengthLevel) + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 11 + } + } + } + + Controls.Label { + text: "Indication uniquement : un mot de passe faible n'est pas bloqué." + color: "#94a3b8" + font.pixelSize: 11 + } + + FieldLabel { + iconName: "dialog-ok-apply" + labelText: "Confirmation" + } + + SecretField { + id: confirmPassword + placeholderText: "Confirmez votre nouveau mot de passe" + } + + Controls.Label { + visible: confirmPassword.text.length > 0 && newPassword.text !== confirmPassword.text + text: "Les deux nouveaux mots de passe ne correspondent pas." + color: "#dc2626" + font.pixelSize: 12 + } + + Rectangle { + visible: root.operationError.length > 0 && root.currentStep === 1 + Layout.fillWidth: true + implicitHeight: errorPasswordRow.implicitHeight + 24 + radius: 12 + color: "#fef2f2" + border.width: 1 + border.color: "#fecaca" + + RowLayout { + id: errorPasswordRow + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + Kirigami.Icon { + source: "dialog-error" + implicitWidth: 20 + implicitHeight: 20 + color: "#dc2626" + } + + Controls.Label { + Layout.fillWidth: true + text: root.operationError + wrapMode: Text.WordWrap + color: "#991b1b" + font.pixelSize: 12 + } + } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + + SecondaryButton { + text: "Retour" + onClicked: root.goBack() + } + + Item { Layout.fillWidth: true } + + PrimaryButton { + text: provisioning.busy ? "Modification…" : "Enregistrer et continuer" + enabled: root.passwordStepValid && !provisioning.busy + opacity: enabled ? 1.0 : 0.45 + onClicked: { + root.operationError = "" + var oldValue = currentPassword.text + var newValue = newPassword.text + var confirmationValue = confirmPassword.text + + provisioning.changePassword(oldValue, newValue, confirmationValue) + + // Les champs graphiques sont vidés immédiatement après transmission + // au backend afin de réduire leur durée de présence en mémoire UI. + currentPassword.text = "" + newPassword.text = "" + confirmPassword.text = "" + } + } + } + } + } + + // --------------------------------- + // 2 - YubiKey + // --------------------------------- + Item { + ColumnLayout { + anchors.fill: parent + spacing: 18 + + RowLayout { + Layout.fillWidth: true + spacing: 20 + + Rectangle { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + radius: 22 + color: "#f0fdf4" + border.width: 1 + border.color: "#bbf7d0" + + Controls.Label { + anchors.centerIn: parent + text: "🔐" + font.pixelSize: 34 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + + Controls.Label { + text: "PIN de votre YubiKey" + color: "#0f172a" + font.pixelSize: 29 + font.weight: Font.Bold + } + + Controls.Label { + Layout.fillWidth: true + text: "Remplacez le PIN temporaire de préparation par un PIN personnel que vous serez seul à connaître." + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 15 + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 78 + radius: 12 + color: "#fffbeb" + border.width: 1 + border.color: "#fde68a" + + RowLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Controls.Label { + text: "⌨" + color: "#b45309" + font.pixelSize: 23 + } + + Controls.Label { + Layout.fillWidth: true + text: "Vérifiez Verr Num avant d'utiliser le pavé numérique. En cas de doute, utilisez les chiffres situés au-dessus des lettres." + wrapMode: Text.WordWrap + color: "#92400e" + font.pixelSize: 13 + } + } + } + + FieldLabel { + iconName: "dialog-password" + labelText: "PIN temporaire actuel" + } + + SecretField { + id: currentPin + placeholderText: "PIN temporaire" + inputMethodHints: Qt.ImhDigitsOnly + } + + FieldLabel { + iconName: "security-high" + labelText: "Nouveau PIN" + } + + SecretField { + id: newPin + placeholderText: "Nouveau PIN personnel" + inputMethodHints: Qt.ImhDigitsOnly + } + + FieldLabel { + iconName: "dialog-ok-apply" + labelText: "Confirmation du nouveau PIN" + } + + SecretField { + id: confirmPin + placeholderText: "Confirmez le nouveau PIN" + inputMethodHints: Qt.ImhDigitsOnly + } + + Controls.Label { + visible: confirmPin.text.length > 0 && newPin.text !== confirmPin.text + text: "Les deux nouveaux PIN ne correspondent pas." + color: "#dc2626" + font.pixelSize: 12 + } + + Rectangle { + visible: root.operationError.length > 0 && root.currentStep === 2 + Layout.fillWidth: true + implicitHeight: errorPinRow.implicitHeight + 24 + radius: 12 + color: "#fef2f2" + border.width: 1 + border.color: "#fecaca" + + RowLayout { + id: errorPinRow + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + Kirigami.Icon { + source: "dialog-error" + implicitWidth: 20 + implicitHeight: 20 + color: "#dc2626" + } + + Controls.Label { + Layout.fillWidth: true + text: root.operationError + wrapMode: Text.WordWrap + color: "#991b1b" + font.pixelSize: 12 + } + } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + + SecondaryButton { + visible: !provisioning.passwordDone + text: "Retour" + enabled: !provisioning.busy + onClicked: root.goBack() + } + + Item { Layout.fillWidth: true } + + PrimaryButton { + text: provisioning.busy ? "Configuration…" : "Configurer la YubiKey" + enabled: root.pinStepValid && !provisioning.busy + opacity: enabled ? 1.0 : 0.45 + onClicked: { + root.operationError = "" + var oldValue = currentPin.text + var newValue = newPin.text + var confirmationValue = confirmPin.text + + provisioning.changePin(oldValue, newValue, confirmationValue) + + currentPin.text = "" + newPin.text = "" + confirmPin.text = "" + } + } + } + } + } + + // --------------------------------- + // 3 - Terminé + // --------------------------------- + Item { + ColumnLayout { + anchors.fill: parent + spacing: 18 + + Item { Layout.fillHeight: true } + + Rectangle { + Layout.preferredWidth: 96 + Layout.preferredHeight: 96 + Layout.alignment: Qt.AlignHCenter + radius: 48 + color: "#dcfce7" + border.width: 1 + border.color: "#86efac" + + Kirigami.Icon { + anchors.centerIn: parent + source: "dialog-ok-apply" + implicitWidth: 52 + implicitHeight: 52 + color: "#16a34a" + } + } + + Controls.Label { + Layout.fillWidth: true + text: "Votre poste est prêt" + color: "#0f172a" + font.pixelSize: 32 + font.weight: Font.Bold + horizontalAlignment: Text.AlignHCenter + } + + Controls.Label { + Layout.maximumWidth: 680 + Layout.alignment: Qt.AlignHCenter + text: "Votre mot de passe local de secours et le PIN personnel de votre YubiKey ont été configurés avec succès. Vos nouveaux secrets restent personnels et ne sont pas enregistrés dans Git." + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 15 + lineHeight: 1.25 + horizontalAlignment: Text.AlignHCenter + } + + Item { Layout.preferredHeight: 12 } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 12 + + Repeater { + model: [ + { "icon": "dialog-password", "title": "Mot de passe", "text": "Accès local configuré" }, + { "icon": "security-high", "title": "YubiKey", "text": "PIN personnel configuré" }, + { "icon": "computer-laptop", "title": "Poste", "text": "Configuration finalisée" } + ] + + Rectangle { + required property var modelData + Layout.preferredWidth: 205 + Layout.preferredHeight: 126 + radius: 16 + color: "#f8fafc" + border.width: 1 + border.color: "#e2e8f0" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 7 + + RowLayout { + Layout.fillWidth: true + + Rectangle { + Layout.preferredWidth: 38 + Layout.preferredHeight: 38 + radius: 11 + color: "#eff6ff" + + Kirigami.Icon { + anchors.centerIn: parent + source: modelData.icon + implicitWidth: 21 + implicitHeight: 21 + color: "#2563eb" + } + } + + Item { Layout.fillWidth: true } + + Rectangle { + Layout.preferredWidth: 25 + Layout.preferredHeight: 25 + radius: 13 + color: "#dcfce7" + + Kirigami.Icon { + anchors.centerIn: parent + source: "dialog-ok-apply" + implicitWidth: 15 + implicitHeight: 15 + color: "#16a34a" + } + } + } + + Controls.Label { + text: modelData.title + color: "#0f172a" + font.pixelSize: 14 + font.weight: Font.DemiBold + } + + Controls.Label { + Layout.fillWidth: true + text: modelData.text + color: "#64748b" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + } + } + } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 12 + + PrimaryButton { + text: "Commencer à travailler" + enabled: root.workflowComplete && !provisioning.busy + opacity: enabled ? 1.0 : 0.45 + onClicked: root.close() + } + } + } + } + } + } + } + } + } + } +}