diff --git a/first-login-ui/src/CMakeLists.txt b/first-login-ui/src/CMakeLists.txt new file mode 100644 index 0000000..57c3b04 --- /dev/null +++ b/first-login-ui/src/CMakeLists.txt @@ -0,0 +1,24 @@ +qt_add_executable(nixos-first-login-ui + main.cpp +) + +qt_add_qml_module(nixos-first-login-ui + URI org.raspot.nixosfirstlogin + VERSION 1.0 + QML_FILES + Main.qml +) + +target_link_libraries(nixos-first-login-ui + PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Qml + Qt6::Quick + Qt6::QuickControls2 +) + +install( + TARGETS nixos-first-login-ui + RUNTIME DESTINATION bin +) diff --git a/first-login-ui/src/Main.qml b/first-login-ui/src/Main.qml new file mode 100644 index 0000000..627d41f --- /dev/null +++ b/first-login-ui/src/Main.qml @@ -0,0 +1,864 @@ +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" } + ] + + function goNext() { + if (currentStep < steps.length - 1) { + currentStep += 1 + } + } + + function goBack() { + if (currentStep > 0) { + currentStep -= 1 + } + } + + Shortcut { + sequence: "Ctrl+Q" + onActivated: Qt.quit() + } + + 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 + Layout.fillWidth: true + implicitHeight: 50 + echoMode: TextInput.Password + passwordCharacter: "●" + font.pixelSize: 15 + leftPadding: 16 + rightPadding: 16 + selectByMouse: true + + background: Rectangle { + radius: 10 + color: field.activeFocus ? "#ffffff" : "#f8fafc" + border.width: field.activeFocus ? 2 : 1 + border.color: field.activeFocus ? "#2563eb" : "#cbd5e1" + } + } + + 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 + + ColumnLayout { + Layout.alignment: Qt.AlignTop + spacing: 0 + + StepDot { stepIndex: index } + + Rectangle { + visible: index < root.steps.length - 1 + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 2 + Layout.preferredHeight: 36 + color: index < root.currentStep ? "#22c55e" : "#28517e" + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: 3 + + Controls.Label { + text: modelData.title + color: index === root.currentStep ? "white" : "#c1d3e8" + font.pixelSize: 15 + font.weight: index === root.currentStep ? Font.DemiBold : Font.Medium + } + + Controls.Label { + text: modelData.subtitle + color: "#7699c2" + font.pixelSize: 12 + } + } + } + } + } + + 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 } + + Controls.Label { + text: "Ctrl+Q : quitter l'aperçu" + color: "#6289b6" + font.pixelSize: 11 + Layout.alignment: Qt.AlignHCenter + } + } + } + + // ----------------------------------------------------- + // 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: "#fff7ed" + border.width: 1 + border.color: "#fed7aa" + + Controls.Label { + id: previewLabel + anchors.centerIn: parent + text: "MODE APERÇU — aucune modification système" + color: "#c2410c" + font.pixelSize: 11 + font.weight: Font.Bold + } + } + + Item { Layout.fillWidth: true } + + SecondaryButton { + text: "Quitter l'aperçu" + onClicked: Qt.quit() + } + } + + 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 + + Controls.Label { + text: "Mot de passe local" + color: "#0f172a" + font.pixelSize: 29 + font.weight: Font.Bold + } + + Controls.Label { + Layout.maximumWidth: 720 + 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 + + Controls.Label { + text: "ℹ" + color: "#2563eb" + font.pixelSize: 22 + font.weight: Font.Bold + } + + Controls.Label { + Layout.fillWidth: true + text: "Aperçu uniquement : les champs ci-dessous ne modifient actuellement aucun mot de passe." + wrapMode: Text.WordWrap + color: "#1e40af" + font.pixelSize: 13 + } + } + } + + Item { Layout.preferredHeight: 4 } + + Controls.Label { + text: "Mot de passe temporaire actuel" + color: "#334155" + font.pixelSize: 13 + font.weight: Font.DemiBold + } + + SecretField { + id: currentPassword + placeholderText: "Saisissez le mot de passe temporaire" + } + + Controls.Label { + text: "Nouveau mot de passe" + color: "#334155" + font.pixelSize: 13 + font.weight: Font.DemiBold + } + + SecretField { + id: newPassword + placeholderText: "Choisissez votre mot de passe personnel" + } + + Controls.Label { + text: "Confirmation" + color: "#334155" + font.pixelSize: 13 + font.weight: Font.DemiBold + } + + 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 + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + + SecondaryButton { + text: "Retour" + onClicked: root.goBack() + } + + Item { Layout.fillWidth: true } + + PrimaryButton { + text: "Continuer" + enabled: newPassword.text.length > 0 && newPassword.text === confirmPassword.text + opacity: enabled ? 1.0 : 0.45 + onClicked: root.goNext() + } + } + } + } + + // --------------------------------- + // 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 + } + } + } + + Controls.Label { + text: "PIN temporaire actuel" + color: "#334155" + font.pixelSize: 13 + font.weight: Font.DemiBold + } + + SecretField { + id: currentPin + placeholderText: "PIN temporaire" + inputMethodHints: Qt.ImhDigitsOnly + } + + Controls.Label { + text: "Nouveau PIN" + color: "#334155" + font.pixelSize: 13 + font.weight: Font.DemiBold + } + + SecretField { + id: newPin + placeholderText: "Nouveau PIN personnel" + inputMethodHints: Qt.ImhDigitsOnly + } + + Controls.Label { + text: "Confirmation du nouveau PIN" + color: "#334155" + font.pixelSize: 13 + font.weight: Font.DemiBold + } + + 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 + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + + SecondaryButton { + text: "Retour" + onClicked: root.goBack() + } + + Item { Layout.fillWidth: true } + + PrimaryButton { + text: "Finaliser" + enabled: newPin.text.length >= 4 && newPin.text === confirmPin.text + opacity: enabled ? 1.0 : 0.45 + onClicked: root.goNext() + } + } + } + } + + // --------------------------------- + // 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" + + Controls.Label { + anchors.centerIn: parent + text: "✓" + color: "#16a34a" + font.pixelSize: 50 + font.weight: Font.Bold + } + } + + 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: "Dans la version finale, cet écran confirmera que votre mot de passe local et le PIN de votre YubiKey ont été personnalisés avec succès." + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 15 + lineHeight: 1.25 + horizontalAlignment: Text.AlignHCenter + } + + Item { Layout.preferredHeight: 12 } + + Rectangle { + Layout.preferredWidth: 520 + Layout.preferredHeight: 112 + Layout.alignment: Qt.AlignHCenter + radius: 16 + color: "#f8fafc" + border.width: 1 + border.color: "#e2e8f0" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 10 + + RowLayout { + Controls.Label { text: "✓"; color: "#16a34a"; font.pixelSize: 17; font.weight: Font.Bold } + Controls.Label { text: "Mot de passe personnel configuré"; color: "#334155"; font.pixelSize: 14 } + } + + RowLayout { + Controls.Label { text: "✓"; color: "#16a34a"; font.pixelSize: 17; font.weight: Font.Bold } + Controls.Label { text: "PIN personnel YubiKey configuré"; color: "#334155"; font.pixelSize: 14 } + } + } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 12 + + SecondaryButton { + text: "Revoir" + onClicked: root.currentStep = 0 + } + + PrimaryButton { + text: "Fermer l'aperçu" + onClicked: Qt.quit() + } + } + } + } + } + } + } + } + } + } +} diff --git a/first-login-ui/src/main.cpp b/first-login-ui/src/main.cpp new file mode 100644 index 0000000..cc24169 --- /dev/null +++ b/first-login-ui/src/main.cpp @@ -0,0 +1,32 @@ +#include +#include +#include +#include + +int main(int argc, char *argv[]) +{ + QGuiApplication app(argc, argv); + + QGuiApplication::setOrganizationName(QStringLiteral("Raspot")); + QGuiApplication::setOrganizationDomain(QStringLiteral("raspot.in")); + QGuiApplication::setApplicationName(QStringLiteral("NixOS First Login")); + QGuiApplication::setDesktopFileName(QStringLiteral("org.raspot.nixosfirstlogin")); + + QIcon::setThemeName(QStringLiteral("breeze")); + + // L'UI est volontairement dessinée par l'application afin d'avoir + // un rendu stable et homogène, tout en restant une application Qt/QML. + if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) { + QQuickStyle::setStyle(QStringLiteral("Basic")); + } + + QQmlApplicationEngine engine; + engine.loadFromModule(QStringLiteral("org.raspot.nixosfirstlogin"), + QStringLiteral("Main")); + + if (engine.rootObjects().isEmpty()) { + return 1; + } + + return app.exec(); +}