diff --git a/docs/adr/0003-gestion-reactive-des-overlays-et-popovers.md b/docs/adr/0003-gestion-reactive-des-overlays-et-popovers.md index 741868ca..64fb75cf 100644 --- a/docs/adr/0003-gestion-reactive-des-overlays-et-popovers.md +++ b/docs/adr/0003-gestion-reactive-des-overlays-et-popovers.md @@ -29,5 +29,5 @@ L'architecture initiale présentait plusieurs fragilités et dettes techniques : - **Élimination complète des hacks DOM** : Plus aucun appel à `querySelector` pour manipuler ou supprimer des éléments de composants tiers. - **Robustesse et extensibilité** : Tout nouvel overlay ou panneau ajouté dans l'application peut s'abonner à `OverlayManagerService` sans modifier `OverviewComponent`. - **Fidélité visuelle et fin des sauts d'affichage** : Élimination définitive des scintillements et des sauts de dropdowns sous la barre de navigation. -- **Règle d'architecture formalisée** : Mise à jour de `GEMINI.md` imposant l'usage exclusif de `OverlayManagerService` pour coordonner la fermeture des overlays. +- **Règle d'architecture formalisée** : Règle imposant l'usage exclusif de `OverlayManagerService` pour coordonner la fermeture des overlays. - **Périmètre et frontière claire** : `OverlayManagerService` est strictement restreint à la publication/souscription d'événements de fermeture ; les interactions de navigation clavier/souris au sein des fenêtres de suggestions sont découplées dans une directive dédiée (voir ADR-0005). diff --git a/docs/adr/0006-authentification-multi-fournisseurs-pattern-strategie.md b/docs/adr/0006-authentification-multi-fournisseurs-pattern-strategie.md new file mode 100644 index 00000000..678564d0 --- /dev/null +++ b/docs/adr/0006-authentification-multi-fournisseurs-pattern-strategie.md @@ -0,0 +1,63 @@ +# 0006 - Authentification multi-forges découplée (Pattern Stratégie & Fournisseurs) et OAuth 2.0 PKCE + +## Contexte +Historiquement, Git4School Visu ne prenait en charge que GitHub via un service monolithique `AuthService` s'appuyant directement sur Firebase Authentication (`firebase.auth().signInWithPopup(githubProvider)`). + +L'introduction du support de GitLab (dans un premier temps la plateforme SaaS GitLab Cloud `gitlab.com`, puis ultérieurement des instances auto-hébergées GitLab Community) a nécessité la conception d'un mécanisme d'authentification robuste, sécurisé et entièrement exécutable côté client. + +L'architecture initiale présentait plusieurs limites structurelles : +1. **Couplage fort au fournisseur GitHub** : `AuthService` était fortement couplé à GitHub et injecté dans plus de quinze composants et services (gardes de routage, barre de navigation, modales, assistants de devoirs). +2. **Absence de contrat d'abstraction** : Aucun contrat formel (interface ou classe abstraite) ne définissait les responsabilités d'un fournisseur d'authentification Git, contraignant les consommateurs à connaître les détails d'implémentation de la forge. +3. **Contraintes de sécurité d'une SPA statique** : Git4School étant une Single Page Application distribuée sans serveur applicatif dédié (hébergée statiquement sur Firebase Hosting), le flux OAuth standard avec secret client (*Authorization Code Grant*) était exclu sous peine de compromettre le secret d'application dans le bundle public. Le standard de l'industrie pour les clients publics sans secret est le protocole **OAuth 2.0 avec PKCE** (*Proof Key for Code Exchange*, RFC 7636). +4. **Duplication d'interface dans la modale d'ajout de compte** : Les panneaux de connexion de la modale (`AddAccountModalComponent`) dupliquaient l'arborescence HTML pour chaque onglet de forge. +5. **Conditions de course et latences d'API à l'authentification** : Les passerelles d'API distribuées (API Gateway de GitLab Cloud) peuvent présenter une brève latence de propagation du jeton nouvellement émis, renvoyant de façon transitoire une erreur 401 Unauthorized lors de l'appel immédiat à `/api/v4/user`. Sans reprise sur erreur et sans affectation atomique de l'état, cela pouvait conduire à des profils orphelins partiels. + +## Options considérées +1. **Évolution du service monolithique `AuthService` avec branches conditionnelles** : + - *Rejeté* : Violait le principe de responsabilité unique (SRP) et le principe Ouvert/Fermé (OCP). Le service aurait combiné Firebase Auth, le protocole PKCE, la gestion multi-tokens et des requêtes HTTP hétérogènes. Tout ajout ultérieur de forge aurait requis la modification du code existant avec risques de régression. +2. **Fournisseurs indépendants injectés individuellement dans les composants** : + - *Rejeté* : Violait le principe d'inversion des dépendances (DIP). Les composants consommateurs auraient dû injecter concrètement `GithubAuthService`, `GitlabAuthService`, etc., multipliant le couplage et complexifiant la vérification globale de session. +3. **Conservation d'un alias de transition `AuthService` pointant vers `GithubAuthService`** : + - *Rejeté* : Introduisait une dette technique et une ambiguïté sémantique persistante. Un renommage intégral et sans compromis de toutes les occurrences dans la base de code a été préféré. +4. **Pattern Stratégie / Fournisseur avec contrat unifié `GitAuthProvider` et registre polymorphique `AccountsService` (Option retenue)**. + +## Décision +1. **Contrat d'abstraction `GitAuthProvider` (`src/app/models/GitAuthProvider.model.ts`)** : + - Définition d'une interface formelle définissant le contrat de tout fournisseur d'authentification : + - Propriétés : `providerType: GitProviderType`. + - Méthodes du cycle de vie : `signIn(): Promise`, `signOut(): Promise`, `isSignedIn(): boolean`. + - Accesseurs de données : `getToken(): string | null`, `getUserProfile(): any | null`. + - Disponibilité fonctionnelle : `isAvailable(): boolean` (permettant la désactivation contextuelle via les feature flags). +2. **Spécialisation de la stratégie GitHub (`GithubAuthService`)** : + - Renommage exhaustif de `AuthService` en `GithubAuthService` à travers toute l'application (zéro dette technique, zéro shim de compatibilité). + - Implémentation du contrat `GitAuthProvider` au-dessus de Firebase Auth. +3. **Implémentation de la stratégie GitLab Cloud via OAuth 2.0 PKCE (`GitlabAuthService`)** : + - Implémentation complète de la RFC 7636 côté client sans dépendance tierce lourde : + - Génération d'un `code_verifier` aléatoire sécurisé via `window.crypto.getRandomValues`. + - Dérivation du `code_challenge` par hachage cryptographique `SHA-256` encodé en Base64-URL (`code_challenge_method=S256`). + - Validation d'état CSRF (`state`) pour prévenir les attaques par falsification de requête inter-sites. + - Fenêtre popup d'authentification avec composant callback dédié (`GitlabCallbackComponent`, route `/auth/callback`) communiquant le code d'autorisation via `window.postMessage` avec fallback synchronisé sur l'événement `storage` de `localStorage`. + - **Atomicité de l'état et résilience aux latences de passerelle** : + - L'affectation de `token` et de `currentUser` est strictement atomique : l'état n'est mis à jour qu'une fois le profil complet validé par l'API. + - Mécanisme de nouvelle tentative automatique (délai de 500 ms) en cas de code 401 passager lors de la requête `/api/v4/user` afin d'absorber la propagation de réplication du token sur les passerelles d'API GitLab. +4. **Registre polymorphique centralisé (`AccountsService`)** : + - Enregistrement des stratégies d'authentification dans un registre typé `Map`. + - API de haut niveau pour les composants : `getProvider(type)`, `signIn(type)`, `signOut(type)`, `hasAccount(type)`, `getAccounts()`, `isEmpty()`. + - Source de vérité unique pour les comptes actifs dans l'application. +5. **Découplage de la garde d'authentification (`AuthGuard`)** : + - `AuthGuard` ne dépend plus directement de Firebase ni de `GithubAuthService`. + - La protection des routes s'appuie désormais sur la méthode universelle `!accountsService.isEmpty()`. +6. **Factorisation de l'interface utilisateur (`AddAccountModalComponent`)** : + - Remplacement de la duplication de code par des blocs réutilisables `ng-template` (`#connectedPanel`, `#disconnectedPanel`) utilisant `ngTemplateOutlet` avec passage de contexte polymorphique. + - Découplage de la vue vis-à-vis des services concrets (seul `AccountsService` est manipulé). + - Affichage dynamique du badge de sécurité indiquant le protocole utilisé (`ACCOUNTS.AUTH_TYPE_OAUTH_PKCE`). + +## Conséquences +- **Conformité aux principes SOLID** : + - *SRP* : Chaque fournisseur gère sa propre logique d'authentification et ses protocoles spécifiques. + - *OCP* : L'ajout de nouveaux fournisseurs (instances GitLab Community auto-hébergées, forge académique, Bitbucket) se fait par simple extension de `GitAuthProvider` et ajout dans le registre de `AccountsService`, sans modifier les composants et gardes existants. + - *LSP* : Tout consommateur manipulant `GitAuthProvider` peut utiliser indifféremment l'une ou l'autre des implémentations. + - *DIP* : Les composants de l'application dépendent d'abstractions contractuelles et non d'implémentations concrètes de SDK tiers. +- **Sécurité et conformité client-side** : Intégration conforme aux recommandations de l'IETF pour les clients publics sans exposition de secret d'application. +- **Résilience opérationnelle** : Neutralisation des conditions de course et tolérance aux latences de réplication des tokens OAuth. +- **Base de code assainie** : Élimination définitive des dettes techniques de renommage et factorisation des templates d'interface. diff --git a/docs/adr/0007-services-de-donnees-git-pattern-strategie-et-facade.md b/docs/adr/0007-services-de-donnees-git-pattern-strategie-et-facade.md new file mode 100644 index 00000000..0ea5ea1e --- /dev/null +++ b/docs/adr/0007-services-de-donnees-git-pattern-strategie-et-facade.md @@ -0,0 +1,48 @@ +# 0007 - Services de données Git multi-fournisseurs (Pattern Stratégie, Façade & Registre) + +## Contexte +Historiquement, la récupération des données de dépôts et de commits dans Git4School était concentrée dans un service monolithique `CommitsService`. Ce dernier combinait plus de 700 lignes de requêtes GraphQL spécifiques à l'API GitHub (`api.github.com`) avec les calculs statistiques et dictionnaires nécessaires aux visualisations D3.js. + +L'intégration de GitLab (GitLab Cloud `gitlab.com` et futures instances académiques auto-hébergées) imposait de : +1. Découpler la logique de récupération de données distantes des algorithmes d'analyse et de visualisation. +2. Définir un contrat d'abstraction formel pour les fournisseurs de données Git. +3. Permettre la résolution dynamique de l'hôte distant (*instance host*) afin de supporter nativement les instances GitLab auto-hébergées sans modifier le code applicatif. +4. Conserver une façade unifiée et rétrocompatible pour les composants graphiques existants. + +## Options considérées +1. **Ajout de conditions `if (provider === 'gitlab')` au sein de `CommitsService`** : + - *Rejeté* : Violation flagrante du principe Ouvert/Fermé (OCP) et du principe de responsabilité unique (SRP). Le service aurait accumulé les spécificités de multiples API (GraphQL GitHub vs REST v4 GitLab) au sein du même fichier. +2. **Injection directe des services de données concrets dans chaque composant** : + - *Rejeté* : Couplage fort des composants d'interface aux détails de chaque forge et duplication de la logique de sélection de stratégie. +3. **Architecture découplée en trois volets : Contrat `GitDataService`, Stratégies concrètes (`GithubDataService`, `GitlabDataService`), Registre polymorphique (`AccountsService`) et Façade (`CommitsService`) (Option retenue)**. + +## Décision +1. **Contrat d'abstraction `GitDataService` (`src/app/models/GitDataService.model.ts`)** : + - Définit le contrat unifié de données Git : + - `readonly provider: GitProviderType` + - `getRepositories(repoTab: Repository[], startDate?: string, endDate?: string): Observable` + - `getRepositoriesByAuthenticatedUser(cursor?: string, pageLimit?: number): Observable` + - `getRepositoriesBySearch(searchFilter: string, cursor?: string, pageLimit?: number): Observable` + - `verifyUserAccess(repoUrl: string): Observable` +2. **Stratégie GitHub (`GithubDataService`)** : + - Encapsule les requêtes GraphQL optimisées par lots (*batch queries* de 4 dépôts), la pagination des commits via curseur et la recherche ciblée (dépôt, organisation, recherche globale). +3. **Stratégie GitLab (`GitlabDataService`)** : + - Implémente l'API REST v4 de GitLab (`/api/v4/projects`). + - **Support natif des instances auto-hébergées** : Extraction dynamique de l'origine de l'hôte (`new URL(repo.url).origin`) et du chemin de projet encodé (`encodeURIComponent(pathWithNamespace)`). + - Récupération unifiée des fichiers `IDENTITY.json` et `README.md` via l'API raw files et pagination récursive des commits (`x-next-page`). +4. **Registre centralisé (`AccountsService`)** : + - Maintient la table de correspondance `dataServices: Map`. + - Expose `getDataService(provider: GitProviderType): GitDataService` et `hasAccount(provider: GitProviderType): boolean`. +5. **Façade unifiée (`CommitsService`)** : + - Délègue les opérations de chargement et de recherche au `GitDataService` correspondant au type de devoir (`assignment.provider`). + - Conserve les fonctions de calcul de graphes (dictionnaires de questions, métadonnées, étudiants) en tant que logique métier pure indépendante du fournisseur. +6. **Mise à jour des formulaires et de la modale d'ajout de dépôts** : + - `ConfigurationComponent`, `EditRepositoriesComponent` et `ModalAddRepositoriesComponent` propagent le type de fournisseur et conditionnent les actions à l'authentification effective sur le fournisseur ciblé (`isConnectedToProvider`). +7. **Rétrocompatibilité et migration Dexie v4** : + - Migration automatique fixant `provider = "github"` sur tous les devoirs et dépôts préexistants. + +## Conséquences +- **Extensibilité** : L'ajout d'une nouvelle forge (ex: forge d'enseignement hébergée, Bitbucket) ne nécessite que la création d'un service implémentant `GitDataService` et son enregistrement dans le registre. +- **Rétrocompatibilité totale** : Les devoirs créés avant la mise à jour continuent de fonctionner à l'identique avec GitHub. +- **Homogénéité mono-fournisseur garantie** : Un devoir est rattaché à une forge unique, évitant les mélanges complexes d'identifiants et de droits d'accès. +- **Expérience utilisateur fluide** : Le bouton de création d'un devoir s'adapte dynamiquement au filtre sélectionné par l'enseignant, tout en offrant 3 déclinaisons d'interaction ergonomiques évaluables via le prototype. diff --git a/docs/adr/0008-memorisation-securisee-tokens-forges.md b/docs/adr/0008-memorisation-securisee-tokens-forges.md new file mode 100644 index 00000000..9b839d8b --- /dev/null +++ b/docs/adr/0008-memorisation-securisee-tokens-forges.md @@ -0,0 +1,77 @@ +# 0008 - Mémorisation sécurisée des jetons d'accès aux forges (sessionStorage vs localStorage) et sensibilisation aux risques XSS + +## Contexte +Avec l'introduction de l'authentification multi-forges (GitHub OAuth et GitLab OAuth 2.0 PKCE, voir [ADR 0006](./0006-authentification-multi-fournisseurs-pattern-strategie.md)), les utilisateurs devaient jusqu'ici se ré-authentifier à chaque fermeture ou réouverture de page (dans le cas de GitLab dont l'état n'était pas persisté), ou dépendaient d'une persistance locale inconditionnelle (Firebase Auth pour GitHub). + +Plusieurs contraintes majeures caractérisent l'architecture de Git4School Visu : +1. **Application cliente statique sans serveur applicatif (Serverless SPA)** : + Git4School est hébergée sous forme d'actifs statiques (HTML/JS/CSS). Les requêtes vers les API des forges (GitHub REST/GraphQL, GitLab REST v4) sont émises directement par le navigateur de l'utilisateur avec un en-tête `Authorization: Bearer ...` ou `token ...`. +2. **Impossibilité d'utiliser des cookies `HttpOnly` pour les API tierces directes** : + Les cookies `HttpOnly` protégés contre les scripts ne peuvent être définis que par le domaine émetteur de l'API ou via un proxy backend inverse dédié. En l'absence de backend intermédiaire, les jetons d'accès doivent nécessairement être manipulés par le code JavaScript côté client. +3. **Risques inhérents au stockage persistant (`localStorage`)** : + - **Accès physique ou machine partagée** : Dans les salles de travaux pratiques ou sur un ordinateur public/partagé, un jeton laissé en `localStorage` survit à la fermeture du navigateur. Tout utilisateur ultérieur sur la même session machine accède directement aux dépôts Git de l'enseignant ou de l'étudiant. + - **Vulnérabilité aux attaques XSS (Cross-Site Scripting)** : Tout script injecté ou extension de navigateur malveillante accédant au contexte de la page peut lire l'intégralité du `localStorage`. + - **Scanners d'extensions et fuite accidentelle** : Les jetons bruts stockés en clair avec des motifs reconnaissables (`gho_`, `glpat-`) peuvent être détectés par des outils tiers ou inclus involontairement dans des rapports d'erreurs. + +L'objectif est d'offrir le choix explicite à l'utilisateur de mémoriser sa session sur son appareil personnel de confiance, tout en garantissant un niveau de sécurité optimal adapté à l'architecture sans backend et en l'alertant en toute transparence sur les risques associés. + +--- + +## Options considérées + +1. **Persistance inconditionnelle en `localStorage` pour tous les utilisateurs** : + - *Rejeté* : Dangereux en environnement éducatif (salles de TP, ordinateurs en libre accès). Risque critique d'usurpation d'identité et d'accès non autorisé aux dépôts des utilisateurs précédents. +2. **Stockage en mémoire vive uniquement (aucun stockage navigateur)** : + - *Rejeté* : Expérience utilisateur dégradée. Tout rafraîchissement de page (`F5`) ou navigation inter-onglets déconnecte instantanément l'utilisateur et interrompt les flux de travail en cours. +3. **Ségrégation conditionnelle `sessionStorage` (par défaut) vs `localStorage` (choix explicite) avec scellement chiffré/obfusqué et purge garantie (Option retenue)**. + +--- + +## Décision + +1. **Principe du "Sécurisé par défaut" (Secure by Default)** : + - Lors de la connexion à une forge (GitHub ou GitLab), la case *"Rester connecté sur cet appareil"* est **décochée par défaut**. + - **Case décochée** : Les jetons d'accès et profils sont stockés dans `sessionStorage`. Dès la fermeture de l'onglet ou du navigateur, le stockage est instantanément détruit par le navigateur, sans laisser de trace sur le disque. + - **Case cochée** : Les données sont enregistrées dans `localStorage` afin de persister d'une session à l'autre sur l'appareil de confiance. + +2. **Service centralisé `TokenStorageService` (`src/app/services/token-storage.service.ts`)** : + - Encapsule l'ensemble des opérations de stockage des identifiants et données de profil pour toutes les forges (`github`, `gitlab`). + - Fournit une API synchrone garantissant la disponibilité immédiate du token lors de l'instanciation des services et gardes Angular, sans race conditions. + +3. **Algorithme de scellement lié à l'appareil et à l'origine avec contrôle d'intégrité** : + - Les jetons ne sont jamais stockés en clair dans le stockage du navigateur. + - À l'écriture, une enveloppe structurée est créée : + - Génération d'un vecteur d'initialisation aléatoire (`iv`). + - Génération et conservation d'un sel cryptographique unique par installation/appareil (`g4s_device_seed`) tiré via `crypto.getRandomValues`, évitant tout secret statique en dur dans le bundle JS. + - Dérivation d'un flux de clé pseudo-aléatoire (LCG) combinant l'origine (`window.location.origin`), le sel d'appareil et l'IV. + - Chiffrement par masque XOR du JSON sérialisé (`token`, `provider`, `createdAt`, `expiresAt`). + - Calcul d'une signature d'intégrité (hachage FNV-1a à double passe) sur l'ensemble `(origine + sel appareil + IV + clair + exp)`. + - À la lecture : + - Vérification de la signature d'intégrité : toute modification externe ou altération de données entraîne le rejet immédiat du jeton et la purge de l'entrée. + - Liaison appareil & origine : un jeton exporté ou injecté sur une autre machine ou une autre origine ne peut être déchiffré. + - Détection des scans de regex : aucun motif de token (`gho_`, `glpat-`) n'apparaît en clair dans les clés ou valeurs de stockage. + +4. **Gestion de l'expiration et éviction automatique (TTL)** : + - Les jetons GitLab OAuth PKCE sont accompagnés d'une durée de validité (`expires_in`, typiquement 7200 secondes). + - L'enveloppe enregistre `expiresAt`. Si `Date.now() > expiresAt` lors de la lecture, le jeton est immédiatement purgé et l'utilisateur est considéré comme déconnecté, évitant des échecs d'appels API silencieux en boucle. + +5. **Purge atomique à la déconnexion (`signOut`)** : + - Lors de la déconnexion, `tokenStorageService.clearAll(provider)` purge simultanément les données dans `sessionStorage` et `localStorage`. + - La session Firebase Auth est également fermée. + +6. **Sensibilisation et avertissement contextuel (Infobulle UX)** : + - Dans `AddAccountModalComponent`, un indicateur d'information avec infobulle interactive (`[appTooltip]`) prévient l'utilisateur : + > *"Le jeton sera stocké dans le localStorage de votre navigateur. Pratique pour éviter de vous reconnecter, mais sensible aux failles XSS et à l'accès physique à l'appareil. À n'activer que sur un ordinateur personnel de confiance."* + +--- + +## Conséquences + +- **Sécurité renforcée en milieu universitaire/scolaire** : + Par défaut, aucun jeton ne persiste sur le disque local. Les étudiants et enseignants utilisant des postes partagés ne risquent pas d'abandonner leur session active après fermeture du navigateur. +- **Transparence totale pour l'utilisateur** : + L'utilisateur est conscient du compromis ergonomie / sécurité (commodité de ne pas retaper ses accès vs exposition en cas de faille XSS ou de machine partagée). +- **Uniformité multi-forges** : + GitHub et GitLab partagent désormais le même contrat et le même cycle de vie de persistance sécurisée via `TokenStorageService`. +- **Nettoyage de la dette technique** : + L'ancien mécanisme de token de développement (`dev_github_token`) et la ré-authentification automatique Firebase par popup (`reauthenticate()`) sont définitivement supprimés au profit de la mémorisation sécurisée explicite *"Rester connecté"* et de la restauration synchrone de session. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..bb34d0fe --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,26 @@ +# 🏛️ Architecture Decision Records (ADRs) + +Ce répertoire contient l'historique formalisé de toutes les décisions d'architecture prises sur **Git4School Visu**. + +👉 **[Consulter la Vitrine Complète des ADRs sur le Wiki GitHub](../wiki/Architecture-Decision-Records.md)** (avec diagrammes d'architecture, analyse des patrons de conception et matrice d'impact). + +--- + +## 📑 Index des décisions + +| ADR | Titre | Domaine | Statut | +| :---: | :--- | :--- | :---: | +| [0001](./0001-keycap-design-system.md) | Standardisation du composant Keycap dans le Design System | Design System / A11y | `Accepté` | +| [0002](./0002-recherche-et-decouverte-des-depots.md) | Stratégie de recherche et découverte des dépôts GitHub GraphQL | Intégration API / UX | `Accepté` | +| [0003](./0003-gestion-reactive-des-overlays-et-popovers.md) | Gestion réactive et découplée des overlays et popovers (Pub/Sub) | Architecture Réactive | `Accepté` | +| [0004](./0004-strategie-de-cloture-des-questions-et-retrocompatibilite-dexie.md) | Stratégie de clôture des questions, import en masse et rétrocompatibilité Dexie | Modèle Métier / IndexedDB | `Accepté` | +| [0005](./0005-navigation-clavier-typeahead-directive-decouplee.md) | Neutralisation des conflits souris-clavier dans les suggestions Typeahead | Ergonomie UI / Événements | `Accepté` | +| [0006](./0006-authentification-multi-fournisseurs-pattern-strategie.md) | Authentification multi-forges découplée (Pattern Stratégie & Fournisseurs) et OAuth 2.0 PKCE | Sécurité / Architecture SOLID | `Accepté` | +| [0007](./0007-services-de-donnees-git-pattern-strategie-et-facade.md) | Services de données Git découplés (Pattern Stratégie, Registre & Façade CommitsService) | Intégration Git / Architecture | `Accepté` | +| [0008](./0008-memorisation-securisee-tokens-forges.md) | Mémorisation sécurisée des jetons d'accès aux forges (sessionStorage vs localStorage) et sensibilisation aux risques XSS | Sécurité / Stockage Client | `Accepté` | + +--- + +## 🧭 Règle pour les contributeurs + +Toute décision technique majeure modifiant l'architecture logicielle, les contrats d'API, la persistance ou l'organisation de l'état doit faire l'objet d'un nouvel ADR rédigé selon le standard du projet et répertorié dans la vitrine d'architecture. diff --git a/docs/wiki/Architecture-Decision-Records.md b/docs/wiki/Architecture-Decision-Records.md new file mode 100644 index 00000000..f96a0084 --- /dev/null +++ b/docs/wiki/Architecture-Decision-Records.md @@ -0,0 +1,292 @@ +# 🏛️ Architecture Decision Records (ADR) — Git4School Visu + +> Vitrine des choix structurants d'architecture, de conception logicielle, d'ergonomie et de sécurité guidant l'évolution pérenne de **Git4School Visu**. + +[![ADRs Documented](https://img.shields.io/badge/ADRs-6%20Documentés-blue.svg?style=flat-square&logo=git)](https://github.com/git4school/git4school-visu/tree/master/docs/adr) +[![Status](https://img.shields.io/badge/Statut-100%25%20Implémenté-success.svg?style=flat-square)](https://github.com/git4school/git4school-visu/tree/master/docs/adr) +[![Architecture](https://img.shields.io/badge/Architecture-Clean%20%2F%20SOLID-orange.svg?style=flat-square)](https://github.com/git4school/git4school-visu) +[![UI Engine](https://img.shields.io/badge/Stack-Angular%20%7C%20D3.js%20%7C%20Dexie-red.svg?style=flat-square)](https://github.com/git4school/git4school-visu) + +--- + +## 📖 Pourquoi des ADRs dans Git4School Visu ? + +Une **ADR (Architectural Decision Record)** est un document court et pragmatique qui capture une décision d'architecture significative, son contexte initial, les options examinées, les justifications techniques et l'ensemble de ses conséquences (positives comme négatives). + +Dans un projet open-source et pédagogique comme **Git4School Visu**, les ADRs remplissent quatre rôles fondamentaux : +1. **Mémoire vivante & Rationale** : Comprendre instantanément *pourquoi* un composant ou un service a été conçu ainsi (par ex. pourquoi le protocole PKCE a été retenu pour GitLab ou pourquoi une directive gère le typeahead). +2. **Prévention des régressions d'architecture** : Éviter de réintroduire d'anciennes dettes techniques ou des anti-patterns déjà résolus (ex. hacks directs sur le DOM ou couplages omniscients). +3. **Alignement d'équipe & Intégration fluide** : Accélérer la montée en compétences des nouveaux contributeurs et partenaires académiques. +4. **Gouvernance technique partagée** : Offrir un cadre de conception strict, auditable et pérenne pour guider l'ensemble des développeurs et contributeurs du projet. + +--- + +## 🗺️ Cartographie & Radar Architectural + +Le schéma ci-dessous illustre l'imbrication des décisions d'architecture à travers les différentes couches de l'application : + +```mermaid +flowchart TB + subgraph Presentation["🎨 Couche Présentation & UI"] + ADR0001["ADR-0001
Design System Keycap
Tokens CSS & Micro-interactions"] + ADR0005["ADR-0005
Directive Typeahead
Découplage Souris/Clavier"] + end + + subgraph Reactive["⚡ Couche Orchestration & Événements"] + ADR0003["ADR-0003
OverlayManagerService
Patron Pub/Sub & NgZone Sync"] + end + + subgraph DataDomain["💾 Couche Métier & Persistance"] + ADR0004["ADR-0004
Clôture des Questions
Dexie v3 & Modèle Metadata"] + end + + subgraph Integration["🔐 Couche Forges Git & Sécurité"] + ADR0002["ADR-0002
Recherche GraphQL GitHub
Découverte Classroom & Orgs"] + ADR0006["ADR-0006
Auth Multi-Forges Découplée
Pattern Stratégie & OAuth PKCE"] + end + + ADR0005 -.->|S'applique à| ADR0004 + ADR0003 -->|Ordonne la fermeture de| ADR0005 + ADR0003 -->|Gère la fermeture de| ADR0001 + ADR0006 -->|Fournit les accès forges à| ADR0002 + ADR0004 -->|Structure les métadonnées de| ADR0002 + + classDef presentation fill:#e1f5fe,stroke:#0288d1,stroke-width:2px,color:#01579b; + classDef reactive fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#e65100; + classDef domain fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,color:#1b5e20; + classDef security fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#4a148c; + + class ADR0001,ADR0005 presentation; + class ADR0003 reactive; + class ADR0004 domain; + class ADR0002,ADR0006 security; +``` + +--- + +## 📊 Tableau de bord des décisions (Index) + +| Réf | Décision | Domaine | Patrons & Concepts Clés | Statut | Fichier Source | +| :---: | :--- | :--- | :--- | :---: | :---: | +| **0001** | [Standardisation du composant Keycap](#adr-0001--standardisation-du-composant-keycap-dans-le-design-system) | Design System / A11y | CSS Custom Properties, Balise sémantique ``, Dark Mode | `Accepté` | [0001-keycap-design-system.md](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0001-keycap-design-system.md) | +| **0002** | [Découverte des dépôts GitHub GraphQL](#adr-0002--stratégie-de-recherche-et-découverte-des-dépôts-github-graphql) | Intégration API / UX | GraphQL Aliases, GitHub Classroom, Normalisation de motif | `Accepté` | [0002-recherche-et-decouverte-des-depots.md](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0002-recherche-et-decouverte-des-depots.md) | +| **0003** | [Gestion réactive des overlays et popovers](#adr-0003--gestion-réactive-et-découplée-des-overlays-et-popovers-pubsub) | Architecture Réactive | Publish-Subscribe, RxJS `Subject`, Synchronisation `NgZone`, D3.js | `Accepté` | [0003-gestion-reactive-des-overlays-et-popovers.md](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0003-gestion-reactive-des-overlays-et-popovers.md) | +| **0004** | [Clôture des questions et migration Dexie](#adr-0004--stratégie-de-clôture-des-questions-import-en-masse-et-rétrocompatibilité-dexie) | Modèle Métier / Stockage | IndexedDB (Dexie v3), Analyse heuristique Git, Parsing délimité | `Accepté` | [0004-strategie-de-cloture-des-questions-et-retrocompatibilite-dexie.md](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0004-strategie-de-cloture-des-questions-et-retrocompatibilite-dexie.md) | +| **0005** | [Navigation clavier typeahead sans conflit](#adr-0005--neutralisation-des-conflits-souris-clavier-dans-les-suggestions-typeahead-directive-déclarative) | Ergonomie UI / Événements | Directive déclarative Angular, OCP/SRP, `runOutsideAngular`, CSS isolates | `Accepté` | [0005-navigation-clavier-typeahead-directive-decouplee.md](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0005-navigation-clavier-typeahead-directive-decouplee.md) | +| **0006** | [Authentification multi-forges et OAuth 2.0 PKCE](#adr-0006--authentification-multi-forges-découplée-pattern-stratégie--fournisseurs-et-oauth-20-pkce) | Sécurité / Architecture SOLID | Pattern Stratégie, Registre polymorphique, RFC 7636 (PKCE), Web Crypto API | `Accepté` | [0006-authentification-multi-fournisseurs-pattern-strategie.md](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0006-authentification-multi-fournisseurs-pattern-strategie.md) | + +--- + +## 🌟 Vitrine détaillée des décisions (Showcase) + +### ADR-0001 : Standardisation du composant Keycap dans le Design System + +> *Harmonisation visuelle, accessibilité sémantique et micro-interactions des touches de raccourcis.* + +``` +┌────────────────────────────────────────────────────────┐ +│ Tags : #DesignSystem #HTML5Sémantique #CSSCustomTokens │ +│ Impact : Global UI, ShortcutsModal, Tooltips, Chooser │ +└────────────────────────────────────────────────────────┘ +``` + +- **Le Défi** : Les touches de raccourcis clavier et indicateurs d'interaction étaient dispersés dans le code avec des implémentations hétérogènes (balises `` vs ``, classes locales `.shortcut-badge`, `.cheat-badge`, `.shortcut-key`, styles d'ombres intérieures vs ombres douces, couleurs hexadécimales en dur). +- **La Décision** : + 1. Centralisation dans `_components.scss` sous les sélecteurs unifiés `kbd, .shortcut-key, .shortcut-badge`. + 2. Standardisation sur la balise sémantique HTML5 native ``. + 3. Support complet du Dark Mode sans aucune couleur en dur, via les tokens CSS globaux (`--color-bg-body`, `--color-border`, `--color-text-primary`, `--color-primary`). + 4. Création de variantes : taille standard (22px), compacte `.shortcut-key-sm` (18px) pour les zones denses, et conteneur `.shortcut-combo` avec séparateur `.key-sep`. + 5. Micro-interaction d'appui physique (`.shortcut-pressed-anim` avec `scale(0.92)`). +- **Bénéfice majeur** : Un composant d'interface universel, responsive, accessible et parfaitement fondu dans le thème clair comme sombre. +- 📄 Consulter l'ADR complet : [`docs/adr/0001-keycap-design-system.md`](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0001-keycap-design-system.md) + +--- + +### ADR-0002 : Stratégie de recherche et découverte des dépôts GitHub GraphQL + +> *Résolution intelligente et arborescente des dépôts étudiants et organisations d'enseignement.* + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Tags : #GraphQL #GitHubClassroom #Algorithme #UXRecherche │ +│ Impact : ModalAddRepositoriesComponent, CommitsService │ +└──────────────────────────────────────────────────────────────┘ +``` + +- **Le Défi** : L'API de recherche globale GitHub n'indexe par défaut que les dépôts publics, rendant les dépôts d'organisations d'enseignement privés (ex: `UE-TOAW`, `CLJ5059A`) introuvables. De plus, la recherche ouverte ramenait des dépôts tiers mondiaux non pertinents, et les dépôts de devoirs créés de manière autonome par GitHub Classroom (sans lien de parenté Git formel `fork`) étaient omis. +- **La Décision** : + 1. **Découverte automatique des organisations** : Récupération des organisations de l'utilisateur avec mise en cache `shareReplay(1)` pour éviter tout appel redondant. + 2. **Multi-requêtes GraphQL parallélisées par alias** : Découpage intelligent selon la saisie (URL directe, préfixe d'organisation, motif `owner/name` ou texte libre). + 3. **Normalisation de motif d'assignation (`extractAssignmentCore`)** : Extraction du cœur sémantique du devoir en éliminant les préfixes d'organisation et suffixes de templates pour identifier à coup sûr tous les dépôts étudiants associés. + 4. **Scission et pliage interactif des résultats** : Séparation claire entre « Correspondances par nom » et « Correspondances dans la description ou le README », avec accordéon interactif. + 5. **Arborescence visuelle des forks** : Dépôts étudiants indentés directement sous le modèle avec guides arborescents `└─`. +- **Bénéfice majeur** : Découverte instantanée et sans friction des promotions entières de TP sans exiger la saisie fastidieuse des dizaines d'URLs étudiantes. +- 📄 Consulter l'ADR complet : [`docs/adr/0002-recherche-et-decouverte-des-depots.md`](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0002-recherche-et-decouverte-des-depots.md) + +--- + +### ADR-0003 : Gestion réactive et découplée des overlays et popovers (Pub/Sub) + +> *Éradication des hacks DOM et synchronisation fluide entre D3.js et le cycle de détection Angular.* + +``` +┌────────────────────────────────────────────────────────────┐ +│ Tags : #RxJS #PubSub #NgZone #D3js #ArchitecturePropre │ +│ Impact : OverviewComponent, TooltipService, Typeahead │ +└────────────────────────────────────────────────────────────┘ +``` + +- **Le Défi** : `OverviewComponent` agissait comme un contrôleur omniscient manipulant impérativement ses enfants via de multiples `@ViewChild`. Des hacks destructifs de DOM (`document.querySelector("ngb-typeahead-window")?.remove()`) étaient utilisés pour forcer la fermeture de popups tiers. L'exécution d'événements de souris dans D3 en dehors de la zone Angular générait des sauts graphiques brutaux de dropdowns sous la barre de navigation. +- **La Décision** : + 1. Création de `OverlayManagerService`, un bus d'événements singleton basé sur le patron **Publish-Subscribe** via un `Subject`. + 2. Typage granulaire des overlays (`TOOLTIP`, `CONTEXT_MENU`, `TYPEAHEAD`, `QUICK_HELP`, `DROPDOWN`, `ALL`). + 3. Sécurisation synchrone systématique dans `ngZone.run()` pour forcer un rafraîchissement d'état Angular immédiat sans étape transitoire brisée. + 4. Auto-gestion des composants récepteurs qui s'abonnent et se détruisent proprement via `takeUntil(destroy$)`. + 5. Règle d'architecture formalisée imposant l'usage exclusif de `OverlayManagerService` pour coordonner la fermeture des éléments flottants. +- **Bénéfice majeur** : Plus aucun appel à `querySelector` pour manipuler le DOM de composants tiers, fin définitive des scintillements et stabilité absolue des graphiques D3. +- 📄 Consulter l'ADR complet : [`docs/adr/0003-gestion-reactive-des-overlays-et-popovers.md`](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0003-gestion-reactive-des-overlays-et-popovers.md) + +--- + +### ADR-0004 : Stratégie de clôture des questions, import en masse et rétrocompatibilité Dexie + +> *Flexibilité pédagogique sur la détection des commits, assistant ergonomique et migration IndexedDB sans perte.* + +``` +┌────────────────────────────────────────────────────────────┐ +│ Tags : #IndexedDB #Dexie #ModèleMétier #Pédagogie │ +│ Impact : Metadata, CommitModel, QuestionsChooser, Popover │ +└────────────────────────────────────────────────────────────┘ +``` + +- **Le Défi** : La détection des questions résolues reposait sur une expression régulière codée en dur ciblant uniquement les verbes GitHub (`fix`, `close`). Les enseignants souhaitant valider les jalons via d'autres mots-clés (`rendu`, `validé`) ou directement par l'identifiant de la question (`[ITER 1]`) étaient bloqués. L'ajout des questions se faisait une à une et risquait de corrompre les devoirs déjà stockés dans IndexedDB. +- **La Décision** : + 1. Enrichissement du modèle `Metadata` avec 3 modes configurables : `standard` (GitHub), `custom` (mots-clés libres de l'enseignant), et `none` (détection directe par nom de question). + 2. Migration de schéma Dexie `version(3)` dans `DatabaseService` garantissant l'initialisation transparente de tous les devoirs existants en base locale sans perte. + 3. Conception du composant `QuestionsAssistantPopoverComponent` intégrant un générateur de séries (`1..N`, `A..Z`), un panneau d'import multi-délimiteurs (`\n`, `,`, `;`, `\t`), et la configuration de la règle de clôture. + 4. Détection intelligente au copier-coller (`paste`) dans le champ des questions avec scission automatique et dédoublonnage. +- **Bénéfice majeur** : Adaptabilité totale à toutes les démarches pédagogiques universitaires et gain de temps massif lors de la configuration des devoirs. +- 📄 Consulter l'ADR complet : [`docs/adr/0004-strategie-de-cloture-des-questions-et-retrocompatibilite-dexie.md`](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0004-strategie-de-cloture-des-questions-et-retrocompatibilite-dexie.md) + +--- + +### ADR-0005 : Neutralisation des conflits souris-clavier dans les suggestions Typeahead (Directive déclarative) + +> *Élimination des sauts visuels lors de la navigation fléchée sans toucher au code des composants consommateurs.* + +``` +┌────────────────────────────────────────────────────────────┐ +│ Tags : #DirectiveAngular #SOLID #OCP #PerformanceDOM │ +│ Impact : SharedUiModule, QuestionsChooser, TextInput │ +└────────────────────────────────────────────────────────────┘ +``` + +- **Le Défi** : Lors de la navigation aux flèches dans les suggestions d'autocomplétion (`ngbTypeahead`), le fait de laisser le curseur de la souris immobile sur la liste déclenchait des événements synthétiques `mouseenter` émis par le navigateur lors des reflows DOM. Cela provoquait des allers-retours chaotiques de sélection et un double surbrillance visuelle `:hover` / `.active`. +- **La Décision** : + 1. Conception d'une directive déclarative `TypeaheadKeyboardNavDirective` ciblant `input[ngbTypeahead]`. + 2. Respect strict du principe Ouvert/Fermé (OCP) : s'applique immédiatement à tous les champs présents et futurs sans modifier leur logique métier. + 3. Gestion éphémère des écouteurs d'événements de rétablissement (`mousemove`, `mousedown`, `wheel`) : rattachés uniquement pendant la phase active de navigation clavier, exécutés hors zone Angular (`runOutsideAngular`) avec un seuil de mouvement de sécurité (> 2px) pour filtrer les micro-événements parasites. + 4. Neutralisation CSS instantanée (`pointer-events: none !important` et suppression des transitions pendant la navigation fléchée). +- **Bénéfice majeur** : Navigation clavier d'une netteté parfaite, zéro fuite mémoire, zéro écouteur passif persistant en arrière-plan. +- 📄 Consulter l'ADR complet : [`docs/adr/0005-navigation-clavier-typeahead-directive-decouplee.md`](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0005-navigation-clavier-typeahead-directive-decouplee.md) + +--- + +### ADR-0006 : Authentification multi-forges découplée (Pattern Stratégie & Fournisseurs) et OAuth 2.0 PKCE + +> *Ouverture multi-fournisseurs (GitHub + GitLab Cloud), sécurité client-side conforme RFC 7636 et zéro secret applicatif exposé.* + +``` +┌────────────────────────────────────────────────────────────┐ +│ Tags : #OAuth2PKCE #PatternStratégie #RFC7636 #Sécurité │ +│ Impact : AccountsService, GithubAuth, GitlabAuth, Guards │ +└────────────────────────────────────────────────────────────┘ +``` + +- **Le Défi** : L'authentification était historiquement liée de manière monolithique à GitHub via Firebase Auth. L'ouverture à GitLab (plateforme cloud puis instances auto-hébergées) imposait de respecter l'architecture Single Page Application (SPA statique distribuée sur Firebase Hosting) : aucun secret client (*Client Secret*) ne pouvait être inclus dans le code client sans compromettre la sécurité. De plus, les API Gateways de GitLab présentaient parfois de légères latences de propagation du token provoquant des erreurs 401 temporaires. +- **La Décision** : + 1. **Contrat d'interface formel `GitAuthProvider`** : Abstraction normalisée unifiant le cycle de vie (`signIn`, `signOut`, `isSignedIn`, `getToken`, `getUserProfile`, `isAvailable`). + 2. **Pattern Stratégie & Registre centralisé (`AccountsService`)** : Source unique de vérité stockant les fournisseurs dans une table de correspondance typée `Map`. + 3. **Implémentation OAuth 2.0 avec PKCE (RFC 7636)** : Flux d'autorisation complet exécuté dans le navigateur sans dépendance externe lourde, utilisant la Web Crypto API (`window.crypto.getRandomValues`, hachage SHA-256 et Base64-URL pour le `code_challenge`, validation du `state` anti-CSRF). + 4. **Résilience et atomicité** : Affectation atomique de la session et mécanisme de nouvelle tentative automatique avec délai d'attente pour absorber les latences de réplication réseau. + 5. **Découplage de `AuthGuard` et polymorphisme d'UI** : `AuthGuard` s'appuie désormais sur `!accountsService.isEmpty()` et la modale d'ajout de compte factorise l'affichage via des templates réutilisables. +- **Bénéfice majeur** : Architecture SOLID hautement extensible permettant d'ajouter facilement toute nouvelle forge (GitLab Community auto-hébergé, Bitbucket, forges universitaires) avec un niveau de sécurité client optimal. +- 📄 Consulter l'ADR complet : [`docs/adr/0006-authentification-multi-fournisseurs-pattern-strategie.md`](https://github.com/git4school/git4school-visu/blob/master/docs/adr/0006-authentification-multi-fournisseurs-pattern-strategie.md) + +--- + +## 🧭 Principes directeurs de l'architecture Git4School Visu + +L'ensemble de ces ADRs applique un socle commun d'exigences et de bonnes pratiques techniques partagées au sein du projet : + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Principes Directeurs Communs │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ 1. SOLID & Clean Code : Interfaces fortes, découplage, responsabilités│ +│ uniques, zéro code dupliqué. │ +│ 2. Isolation D3 / Angular : Respect de NgZone, pas de désynchronisation │ +│ ni de hacks directs sur le DOM tiers. │ +│ 3. Theming & Tokens CSS : Compatibilité Dark Mode native sans couleur │ +│ hexadécimale en dur dans les composants. │ +│ 4. Sécurité Client-Side : Flux OAuth 2.0 PKCE sans secret client exposé │ +│ dans les bundles frontend. │ +│ 5. Rétrocompatibilité : Évolution défensive des schémas IndexedDB │ +│ avec migration automatisée. │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## ✍️ Comment proposer une nouvelle ADR ? + +### Cycle de vie d'une décision + +```mermaid +stateDiagram-v2 + [*] --> Proposé : Rédaction initiale (PR) + Proposé --> Accepté : Validation par l'équipe + Proposé --> Rejeté : Débat & alternatives retenues + Accepté --> Implémenté : Déploiement dans le code + Implémenté --> Remplacé : Évolution majeure (Nouvelle ADR) + Rejeté --> [*] + Remplacé --> [*] +``` + +1. **Numérotation séquentielle** : Créer un fichier Markdown dans `docs/adr/` sous la forme `XXXX-titre-court-en-kebab-case.md` (ex: `0007-nouvelle-decision.md`). +2. **Utiliser le template standard** : Renseigner scrupuleusement le contexte, les options étudiées, la décision finale et ses conséquences. +3. **Mettre à jour cette vitrine** : Ajouter la nouvelle entrée dans le tableau de bord et dans la section Showcase. +4. **Mettre à jour la documentation d'architecture** : Référencer la nouvelle règle dans cette vitrine et documenter les impacts techniques. + +
+📋 Modèle Markdown d'ADR à copier (Template) + +```markdown +# XXXX - [Titre de la décision architecturale] + +## Contexte +[Décrire la situation initiale, les besoins fonctionnels ou techniques, les limites rencontrées et la dette technique à résoudre.] + +## Options considérées +1. **Option A : [Titre]** + - *Avantages / Inconvénients* : [...] + - *Verdict* : [Rejeté - Justification] +2. **Option B : [Titre]** (Option retenue) + - *Avantages / Inconvénients* : [...] + +## Décision +1. **[Composant / Service clé]** : [Détail de l'architecture retenue] +2. **[Patron de conception]** : [Explication du découplage et des contrats] +3. **[Règles d'implémentation]** : [Normes de sécurité, d'ergonomie ou de style] + +## Conséquences +- **Positives** : [Gains en maintenabilité, performance, extensibilité, sécurité...] +- **Contraintes / Négatives** : [Points d'attention, complexité induite éventuelle...] +- **Mises à jour documentaires** : [Mise à jour de la vitrine d'architecture et des guides associés] +``` + +
+ +--- + +*Page maintenue par l'équipe Git4School. Pour toute question ou proposition, ouvrez une issue ou soumettez une Pull Request.* diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 1ad790ff..d810ca88 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -7,6 +7,7 @@ import { QuestionsCompletionComponent } from "@components/graphs/questions-compl import { StudentsComponent } from "@components/graphs/students/students.component"; import { HomeComponent } from "@components/home/home.component"; import { AppNavLayoutComponent } from "@components/nav-layouts/app-nav-layout/app-nav-layout.component"; +import { GitlabCallbackComponent } from "@components/auth-callback/gitlab-callback.component"; import { AuthGuard } from "@guards/auth.guard"; import { DataLoadingGuard } from "@guards/data-loading.guard"; import { DataProvidedGuard } from "@guards/data-provided.guard"; @@ -47,6 +48,7 @@ const APP_ROUTES: Routes = [ ]; const ROUTES: Routes = [ + { path: "auth/callback", component: GitlabCallbackComponent }, { path: "", component: AppNavLayoutComponent, diff --git a/src/app/app.component.ts b/src/app/app.component.ts index b9e4e4de..7ac8f332 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,7 +1,7 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { Router } from "@angular/router"; import { TranslateService } from "@ngx-translate/core"; -import { AuthService } from "@services/auth.service"; +import { GithubAuthService } from "@services/github-auth.service"; import { DataService } from "@services/data.service"; import { ThemeService } from "@services/theme.service"; import * as Chart from "chart.js"; @@ -18,18 +18,18 @@ import { environment } from "@environments/environment"; templateUrl: "./app.component.html", styleUrls: ["./app.component.scss"], }) -export class AppComponent implements OnInit, OnDestroy { +export class AppComponent implements OnInit { readonly isProduction = environment.production; /** * AppComponent constructor - * @param authService Authentication service + * @param githubAuthService Authentication service * @param dataService Service used to store and get data * @param router * @param translateService Service used to translate the application * @param themeService Service used to handle theme */ constructor( - public authService: AuthService, + public githubAuthService: GithubAuthService, public dataService: DataService, private router: Router, public translateService: TranslateService, @@ -38,21 +38,8 @@ export class AppComponent implements OnInit, OnDestroy { /** * This method is called once the component is loaded. - * If the user is not signed in, call the the sign in method. */ ngOnInit(): void { Chart.pluginService.unregister(ChartDataLabels); - this.authService.loading = false; - if (!this.authService.isSignedIn()) { - this.authService.reauthenticate(); - } - } - - /** - * This method is called when the component is destroyed. - * Disconnects the user - */ - ngOnDestroy(): void { - this.authService.signOut(); } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index ff4c269a..298d7696 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -28,7 +28,7 @@ import { AuthGuard } from "@guards/auth.guard"; import { NgbActiveModal, NgbModule } from "@ng-bootstrap/ng-bootstrap"; import { TranslateLoader, TranslateModule, TranslateService } from "@ngx-translate/core"; import { TranslateHttpLoader } from "@ngx-translate/http-loader"; -import { AuthService } from "@services/auth.service"; +import { GithubAuthService } from "@services/github-auth.service"; import { CommitsService } from "@services/commits.service"; import { DataService } from "@services/data.service"; import { DatabaseService } from "@services/database.service"; @@ -52,6 +52,7 @@ import { OverviewGraphContextualMenuComponent } from "./components/overview-grap import { SidebarSettingsComponent } from "./components/nav-layouts/sidebar-settings/sidebar-settings.component"; import { AccountsComponent } from "./components/nav-layouts/sidebar-settings/accounts/accounts.component"; import { AddAccountModalComponent } from "./components/nav-layouts/sidebar-settings/accounts/add-account-modal/add-account-modal.component"; +import { GitlabCallbackComponent } from "@components/auth-callback/gitlab-callback.component"; /** * Firebase configuration file @@ -127,6 +128,7 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj SidebarSettingsComponent, AccountsComponent, AddAccountModalComponent, + GitlabCallbackComponent, ], entryComponents: [AddAccountModalComponent], imports: [ @@ -153,7 +155,7 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj NgxDatatableModule, ], providers: [ - AuthService, + GithubAuthService, AuthGuard, CommitsService, JsonManagerService, diff --git a/src/app/components/auth-callback/gitlab-callback.component.html b/src/app/components/auth-callback/gitlab-callback.component.html new file mode 100644 index 00000000..5f77207c --- /dev/null +++ b/src/app/components/auth-callback/gitlab-callback.component.html @@ -0,0 +1,11 @@ +
+
+
+ {{ 'ACCOUNTS.CONNECTING' | translate }} +
+
{{ 'ACCOUNTS.CONNECTING' | translate }}
+

+ {{ 'ACCOUNTS.CALLBACK_CLOSING' | translate }} +

+
+
diff --git a/src/app/components/auth-callback/gitlab-callback.component.scss b/src/app/components/auth-callback/gitlab-callback.component.scss new file mode 100644 index 00000000..7be94709 --- /dev/null +++ b/src/app/components/auth-callback/gitlab-callback.component.scss @@ -0,0 +1,40 @@ +@import "variables"; + +:host { + display: block; + width: 100vw; + height: 100vh; +} + +/* Callback styling */ +.callback-container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; + background-color: var(--color-bg-body); +} + +.callback-card { + max-width: 380px; + width: 100%; + padding: 32px 24px; + text-align: center; + border-radius: 12px; + background-color: var(--color-surface); + box-shadow: var(--shadow-md); + border: 1px solid var(--color-border); +} + +.callback-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--color-on-surface); + margin-bottom: 8px; +} + +.callback-desc { + font-size: 0.875rem; + color: var(--color-text-secondary); +} diff --git a/src/app/components/auth-callback/gitlab-callback.component.spec.ts b/src/app/components/auth-callback/gitlab-callback.component.spec.ts new file mode 100644 index 00000000..8d16062c --- /dev/null +++ b/src/app/components/auth-callback/gitlab-callback.component.spec.ts @@ -0,0 +1,83 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { ActivatedRoute, Router } from "@angular/router"; +import { TranslateModule } from "@ngx-translate/core"; +import { GitlabCallbackComponent } from "./gitlab-callback.component"; + +describe("GitlabCallbackComponent", () => { + let component: GitlabCallbackComponent; + let fixture: ComponentFixture; + let routerSpy: jasmine.SpyObj; + + beforeEach(async () => { + routerSpy = jasmine.createSpyObj("Router", ["navigate"]); + + await TestBed.configureTestingModule({ + declarations: [GitlabCallbackComponent], + imports: [TranslateModule.forRoot()], + providers: [ + { provide: Router, useValue: routerSpy }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: { + get: (key: string) => { + if (key === "code") return "test-code"; + if (key === "state") return "test-state"; + return null; + }, + }, + }, + }, + }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(GitlabCallbackComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + delete (window as any).opener; + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should postMessage to opener and close if opener exists", fakeAsync(() => { + const postMessageSpy = jasmine.createSpy("postMessage"); + const closeSpy = jasmine.createSpy("close"); + + (window as any).opener = { postMessage: postMessageSpy }; + spyOn(window, "close").and.callFake(closeSpy); + + component.ngOnInit(); + + expect(postMessageSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + type: "GITLAB_OAUTH_CALLBACK", + code: "test-code", + state: "test-state", + error: null, + errorDescription: null, + }), + window.location.origin, + ); + + tick(200); + expect(closeSpy).toHaveBeenCalled(); + })); + + it("should navigate to home if no window.opener", fakeAsync(() => { + delete (window as any).opener; + spyOn(window, "close"); + + component.ngOnInit(); + tick(200); + + expect(routerSpy.navigate).toHaveBeenCalledWith(["/home"]); + })); +}); diff --git a/src/app/components/auth-callback/gitlab-callback.component.ts b/src/app/components/auth-callback/gitlab-callback.component.ts new file mode 100644 index 00000000..b5a8aec2 --- /dev/null +++ b/src/app/components/auth-callback/gitlab-callback.component.ts @@ -0,0 +1,49 @@ +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; + +@Component({ + selector: "app-gitlab-callback", + templateUrl: "./gitlab-callback.component.html", + styleUrls: ["./gitlab-callback.component.scss"], +}) +export class GitlabCallbackComponent implements OnInit { + constructor(private route: ActivatedRoute, private router: Router) {} + + ngOnInit(): void { + const searchParams = new URLSearchParams(window.location.search); + const code = this.route.snapshot.queryParamMap.get("code") || searchParams.get("code"); + const state = this.route.snapshot.queryParamMap.get("state") || searchParams.get("state"); + const error = this.route.snapshot.queryParamMap.get("error") || searchParams.get("error"); + const errorDescription = this.route.snapshot.queryParamMap.get("error_description") || searchParams.get("error_description"); + + const payload = { + type: "GITLAB_OAUTH_CALLBACK", + code, + state, + error, + errorDescription, + timestamp: Date.now(), + }; + + try { + localStorage.setItem("gitlab_oauth_callback_data", JSON.stringify(payload)); + } catch (e) { + console.warn("Could not write oauth callback data to localStorage:", e); + } + + if (window.opener) { + try { + window.opener.postMessage(payload, window.location.origin); + } catch (e) { + console.warn("Could not postMessage to opener:", e); + } + } + + setTimeout(() => { + window.close(); + if (!window.opener) { + this.router.navigate(["/home"]); + } + }, 200); + } +} diff --git a/src/app/components/home/assignment-chooser/assignment-chooser.component.html b/src/app/components/home/assignment-chooser/assignment-chooser.component.html index 45a86d7a..07eada2a 100644 --- a/src/app/components/home/assignment-chooser/assignment-chooser.component.html +++ b/src/app/components/home/assignment-chooser/assignment-chooser.component.html @@ -233,19 +233,110 @@
{{ 'HOME.FILTER.YEAR' | translate }}
-
- + +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ +
+ +
+ + +
+ + +
+
+
diff --git a/src/app/components/home/assignment-chooser/assignment-chooser.component.scss b/src/app/components/home/assignment-chooser/assignment-chooser.component.scss index 47555d64..d2c472c2 100644 --- a/src/app/components/home/assignment-chooser/assignment-chooser.component.scss +++ b/src/app/components/home/assignment-chooser/assignment-chooser.component.scss @@ -1,3 +1,5 @@ +@import 'variables'; + .assignment-container { font-family: 'Inter', 'Roboto', sans-serif; } @@ -24,7 +26,9 @@ } &[data-active="all"] .filter-indicator { transform: translateX(0); } + &[data-active="github"] .filter-indicator { transform: translateX(100px); } + &[data-active="gitlab"] .filter-indicator { transform: translateX(200px); } .filter-btn { @@ -69,7 +73,8 @@ .btn-primary.action-btn { background-color: var(--color-primary); border-color: var(--color-primary); - color: #ffffff; + color: #fff; + &:hover { background-color: var(--color-primary-hover); border-color: var(--color-primary-hover); @@ -80,10 +85,12 @@ border-color: var(--color-border); background-color: var(--color-surface); color: var(--color-text-primary); + &:hover { background-color: var(--color-bg-body); } } + .actions-group { .square-btn { width: 36px; @@ -96,6 +103,57 @@ box-shadow: none !important; } + .creation-interactive-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: flex-end; + } + + .export-btn { + height: 36px; + max-width: 36px; + padding: 0 !important; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; + overflow: hidden; + white-space: nowrap; + transition: max-width 0.3s ease, padding 0.3s ease, background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease; + + .icon { + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + } + + .text { + opacity: 0; + max-width: 0; + overflow: hidden; + margin: 0; + transition: opacity 0.3s ease, max-width 0.3s ease, margin 0.3s ease; + } + + &.export-active { + max-width: 250px; + padding: 0 0.5rem 0 1rem !important; + background-color: var(--color-primary-bg) !important; + border-color: var(--color-primary-bg) !important; + color: var(--color-primary) !important; + + .text { + opacity: 1; + max-width: 200px; + margin-right: 0.5rem; + } + } + } + .expandable-wrapper { position: relative; width: 36px; @@ -145,13 +203,206 @@ } } } + + /* Filter-specific button styling */ + &.provider-filter-wrapper { + .btn-provider-github { + background-color: #24292e; + border-color: #24292e; + color: #fff; + + &:hover { + background-color: #1b1f23; + border-color: #1b1f23; + } + } + + .btn-provider-gitlab { + background: linear-gradient(135deg, #fc6d26 0%, #e24329 100%); + border-color: #fc6d26; + color: #fff; + + &:hover { + background: linear-gradient(135deg, #e24329 0%, #d13a22 100%); + border-color: #e24329; + } + } + } + } + + /* Split Capsule provider button styles */ + .split-provider-wrapper { + .brand-svg { + display: block; + } + + .split-pill-container { + display: inline-flex; + align-items: center; + height: 36px; + border-radius: 8px; + color: #fff; + overflow: hidden; + transition: background 0.2s ease, box-shadow 0.2s ease; + + .split-main-btn { + border: none; + background: transparent; + color: inherit; + height: 100%; + padding: 0 12px; + font-size: 0.85rem; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + + &:focus, + &:focus-visible { + outline: none; + } + + .split-icon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + } + + .split-text { + display: inline-block; + line-height: 1.2; + } + } + + .split-divider { + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.3); + } + + .split-toggle-btn { + border: none; + background: transparent; + color: inherit; + height: 100%; + width: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s ease; + + .chevron-svg { + display: block; + transition: transform 0.2s ease; + } + + &:hover { + background: rgba(255, 255, 255, 0.2); + } + + &:focus, + &:focus-visible { + outline: none; + } + } + + &.is-open .split-toggle-btn .chevron-svg { + transform: rotate(180deg); + } + + &.provider-github { + background-color: #24292e; + + &:hover { + background-color: #1b1f23; + } + } + + &.provider-gitlab { + background: linear-gradient(135deg, #fc6d26 0%, #e24329 100%); + + &:hover { + background: linear-gradient(135deg, #e24329 0%, #d13a22 100%); + } + } + + &.disabled { + opacity: 0.5; + pointer-events: none; + } + } + + .split-drawer { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 180px; + z-index: $zindex-context-menu; + animation: frostedPopIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); + display: flex; + flex-direction: column; + gap: 2px; + + .dropdown-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + padding: 6px 12px; + white-space: nowrap; + + .brand-svg { + width: 14px; + height: 14px; + display: inline-block; + flex-shrink: 0; + } + + .provider-name { + flex: 1; + white-space: nowrap; + } + + .active-check { + margin-left: auto; + flex-shrink: 0; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: auto; + + * { + pointer-events: none; + } + } + } + } } } } +@keyframes frostedPopIn { + from { + opacity: 0; + transform: translateY(-4px) scale(0.96); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + + .search-filters-group { .search-input-wrapper { position: relative; + .search-icon { position: absolute; left: 12px; @@ -161,6 +412,7 @@ z-index: 5; pointer-events: none; } + .modern-search-input { padding-left: 36px; border-radius: 8px; @@ -169,18 +421,21 @@ color: var(--color-text-primary); transition: border-color 0.15s ease-out, background-color 0.15s ease-out, box-shadow 0.15s ease-out; font-size: 0.9rem; + + &::placeholder { + color: var(--color-text-muted); + transition: color 0.2s ease; + } + &:focus { box-shadow: none !important; outline: none; border-color: var(--color-border) !important; + &::placeholder { color: transparent; } } - &::placeholder { - color: var(--color-text-muted); - transition: color 0.2s ease; - } } } @@ -210,10 +465,12 @@ .filter-chips { gap: 0.5rem; + .filter-chip { display: inline-flex; cursor: pointer; margin: 0; + input[type="checkbox"] { display: none; } @@ -275,16 +532,19 @@ color: var(--badge-prepared-text); border-color: var(--badge-prepared-border); } + &.chip-ongoing input[type="checkbox"]:checked + span { background-color: var(--badge-ongoing-bg); color: var(--badge-ongoing-text); border-color: var(--badge-ongoing-border); } + &.chip-finished input[type="checkbox"]:checked + span { background-color: var(--badge-finished-bg); color: var(--badge-finished-text); border-color: var(--badge-finished-border); } + &.chip-default input[type="checkbox"]:checked + span { background-color: var(--badge-default-bg); color: var(--badge-default-text); @@ -303,6 +563,7 @@ color: var(--color-text-primary); border: 1px solid var(--color-border); border-radius: 6px; + &:focus { border-color: var(--color-primary); box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25); @@ -366,6 +627,7 @@ &.disabled-card { opacity: 0.6; + &::before { background: var(--color-bg-body); box-shadow: none; @@ -382,17 +644,6 @@ &::before { left: calc(1.2rem + 40px - 0.5rem); } - - .selection-zone { - .type-indicator-overlay { - opacity: 0; - transform: scale(0.92); - } - .selection-checkbox-overlay { - opacity: 1; - transform: scale(1); - } - } } } @@ -428,11 +679,13 @@ transform: scale(1); z-index: 2; - i { + .fab { font-size: 1.5rem; } - &.github i { color: var(--color-github, #24292e); } - &.gitlab i { color: var(--color-gitlab, #fc6d26); } + + &.github .fa-github { color: var(--color-github, #24292e); } + + &.gitlab .fa-gitlab { color: var(--color-gitlab, #fc6d26); } } .selection-checkbox-overlay { @@ -463,6 +716,53 @@ } } } + + .assignment-card.is-shrunk & { + .type-indicator-overlay { + opacity: 0; + transform: scale(0.92); + } + + .selection-checkbox-overlay { + opacity: 1; + transform: scale(1); + } + } +} + +.btn-icon-action { + width: 32px; + height: 32px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + transition: all 0.2s; + + i { + font-size: 1rem; + } + + &.text-primary { color: var(--color-primary) !important; } + + &.text-secondary { color: var(--color-text-secondary) !important; } + + &.text-danger { color: var(--color-danger) !important; } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + &.text-primary { background: rgba(59, 130, 246, 0.1); color: var(--color-primary-hover) !important; } + + &.text-secondary { background: var(--color-bg-body); color: var(--color-text-primary) !important; } + + &.text-danger { background: rgba(239, 68, 68, 0.1); color: var(--color-danger) !important; } + } } .assignment-title { @@ -484,6 +784,7 @@ opacity: 0.4; color: var(--color-text-muted); } + .meta-item { font-size: 0.8rem; font-weight: 400; @@ -520,6 +821,7 @@ line-height: 1.2; letter-spacing: -0.02em; } + .year { font-size: 0.7rem; color: var(--color-text-secondary); @@ -587,37 +889,6 @@ } } -.btn-icon-action { - width: 32px; - height: 32px; - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - transition: all 0.2s; - - i { - font-size: 1rem; - } - - &.text-primary { color: var(--color-primary) !important; } - &.text-secondary { color: var(--color-text-secondary) !important; } - &.text-danger { color: var(--color-danger) !important; } - - &:hover:not(:disabled) { - &.text-primary { background: rgba(59, 130, 246, 0.1); color: var(--color-primary-hover) !important; } - &.text-secondary { background: var(--color-bg-body); color: var(--color-text-primary) !important; } - &.text-danger { background: rgba(239, 68, 68, 0.1); color: var(--color-danger) !important; } - } - - &:disabled { - opacity: 0.4; - cursor: not-allowed; - } -} - .sort-direction-svg { .sort-arrow { transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); @@ -661,54 +932,10 @@ opacity: 0; transform: translate3d(0, -6px, 0); } + 100% { opacity: 1; transform: translate3d(0, 0, 0); } } - -.export-btn { - height: 36px; - max-width: 36px; - padding: 0 !important; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 6px; - overflow: hidden; - white-space: nowrap; - transition: max-width 0.3s ease, padding 0.3s ease, background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease; - - .icon { - flex-shrink: 0; - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - } - - .text { - opacity: 0; - max-width: 0; - overflow: hidden; - margin: 0; - transition: opacity 0.3s ease, max-width 0.3s ease, margin 0.3s ease; - } - - &.export-active { - max-width: 250px; - padding: 0 0.5rem 0 1rem !important; - background-color: var(--color-primary-bg) !important; - border-color: var(--color-primary-bg) !important; - color: var(--color-primary) !important; - - .text { - opacity: 1; - max-width: 200px; - margin-right: 0.5rem; - } - } -} - diff --git a/src/app/components/home/assignment-chooser/assignment-chooser.component.ts b/src/app/components/home/assignment-chooser/assignment-chooser.component.ts index 3280bec7..5427ae95 100644 --- a/src/app/components/home/assignment-chooser/assignment-chooser.component.ts +++ b/src/app/components/home/assignment-chooser/assignment-chooser.component.ts @@ -1,12 +1,15 @@ -import { Component, OnInit, TemplateRef, ViewChild, ChangeDetectorRef, OnDestroy } from "@angular/core"; +import { Component, OnInit, TemplateRef, ViewChild, ChangeDetectorRef, OnDestroy, HostListener } from "@angular/core"; import { Router } from "@angular/router"; import { Assignment } from "@models/Assignment.model"; +import { GitProviderType } from "@models/GitAuthProvider.model"; import { TranslateService } from "@ngx-translate/core"; import { AssignmentsService } from "@services/assignments.service"; -import { AuthService } from "@services/auth.service"; +import { AccountsService } from "@services/accounts.service"; +import { GithubAuthService } from "@services/github-auth.service"; import { ConfigurationService } from "@services/configuration.service"; import { DataService } from "@services/data.service"; import { DatabaseService } from "@services/database.service"; +import { OverlayManagerService } from "@services/overlay-manager.service"; import { ToastService } from "@services/toast.service"; import { Subscription } from "rxjs"; import * as moment from "moment"; @@ -18,7 +21,6 @@ import * as moment from "moment"; }) export class AssignmentChooserComponent implements OnInit, OnDestroy { assignments: any[]; // Using any to attach UI-specific properties temporarily - private dbSubscription: Subscription; sortField = "lastModificationDate"; sortDirection: "asc" | "desc" = "desc"; @@ -43,19 +45,59 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { availablePrograms: string[] = []; availableYears: string[] = []; - // Selection state selectionMode = false; selectedAssignments: Set = new Set(); hoveredAssignment: number | null = null; // Status hover preview state hoveredStatusPreview: string | null = null; - private statusPreviewTimeout: any = null; + + // Provider split button dropdown state + isProviderDropdownOpen = false; + lastUsedProvider: GitProviderType = "github"; // Inline edit state editingAssignmentId: number | null = null; isCreatingNew = false; + isSortHovered = false; + sortWasClicked = false; + + private dbSubscription?: Subscription; + private overlaySub: Subscription | null = null; + private accountsSub?: Subscription; + private statusPreviewTimeout: any = null; + + constructor( + private databaseService: DatabaseService, + private dataService: DataService, + private router: Router, + public githubAuthService: GithubAuthService, + public accountsService: AccountsService, + private overlayManager: OverlayManagerService, + private translateService: TranslateService, + private toastService: ToastService, + private assignmentsService: AssignmentsService, + private configurationService: ConfigurationService, + private cdr: ChangeDetectorRef, + ) {} + + @HostListener("document:click", ["$event"]) + onDocumentClick(event: MouseEvent): void { + const target = event.target as HTMLElement; + if (!target || !target.isConnected) { + return; + } + if (!target.closest(".creation-interactive-wrapper")) { + this.closeAllCreationPopovers(); + } + } + + @HostListener("window:keydown.escape") + onEscape(): void { + this.closeAllCreationPopovers(); + } + get filteredAssignments() { let result = this.assignments; @@ -198,17 +240,33 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { this.cdr.markForCheck(); } - constructor( - private databaseService: DatabaseService, - private dataService: DataService, - private router: Router, - public authService: AuthService, - private translateService: TranslateService, - private toastService: ToastService, - private assignmentsService: AssignmentsService, - private configurationService: ConfigurationService, - private cdr: ChangeDetectorRef, - ) {} + isAssignmentConnected(assignment: any): boolean { + if (!assignment) { + return false; + } + const provider = (assignment.provider || assignment.uiType || "github") as GitProviderType; + return this.accountsService.hasAccount(provider); + } + + get isGithubConnected(): boolean { + return this.accountsService.isGithubConnected; + } + + get isGitlabConnected(): boolean { + return this.accountsService.isGitlabConnected; + } + + get canCreateAny(): boolean { + return !this.accountsService.isEmpty(); + } + + get hasMultipleProviders(): boolean { + return this.isGithubConnected && this.isGitlabConnected; + } + + get singleConnectedProvider(): GitProviderType { + return this.isGithubConnected ? "github" : "gitlab"; + } ngOnInit(): void { this.assignments = []; @@ -217,6 +275,29 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { this.dbSubscription = this.databaseService.dbChanged.subscribe(() => { this.loadAssignments(); }); + + this.overlaySub = this.overlayManager.dismiss$.subscribe(() => { + this.closeAllCreationPopovers(); + }); + + this.accountsSub = this.accountsService.accounts$.subscribe(() => { + this.cdr.markForCheck(); + }); + } + + ngOnDestroy(): void { + if (this.dbSubscription) { + this.dbSubscription.unsubscribe(); + } + if (this.overlaySub) { + this.overlaySub.unsubscribe(); + } + if (this.accountsSub) { + this.accountsSub.unsubscribe(); + } + if (this.statusPreviewTimeout) { + clearTimeout(this.statusPreviewTimeout); + } } loadPreferences() { @@ -231,6 +312,10 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { console.error("Could not load preferences", e); } } + const savedProvider = localStorage.getItem("git4school_last_provider") as GitProviderType; + if (savedProvider && (savedProvider === "github" || savedProvider === "gitlab")) { + this.lastUsedProvider = savedProvider; + } } savePreferences() { @@ -244,15 +329,6 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { ); } - ngOnDestroy(): void { - if (this.dbSubscription) { - this.dbSubscription.unsubscribe(); - } - if (this.statusPreviewTimeout) { - clearTimeout(this.statusPreviewTimeout); - } - } - async loadAssignments() { // Preserve editing state if possible, unless it's a new assignment const currentEditingId = this.editingAssignmentId; @@ -287,6 +363,14 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { this.sortAssignments(); + // Fallback: if no provider is explicitly stored in localStorage, use provider of most recent assignment + if (!localStorage.getItem("git4school_last_provider")) { + const lastAssignment = this.assignments.find((a) => a.provider && a.id !== -1); + if (lastAssignment && lastAssignment.provider) { + this.lastUsedProvider = lastAssignment.provider; + } + } + // Restore new assignment if we were creating one if (wasCreatingNew && newAssignmentObj) { this.assignments.unshift(newAssignmentObj); @@ -377,11 +461,7 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { } computeType(assignment: Assignment): "github" | "gitlab" { - // Mock logic: Assign gitlab if title contains 'gitlab', else github - if (assignment.title && assignment.title.toLowerCase().includes("gitlab")) { - return "gitlab"; - } - return "github"; + return assignment.provider || "github"; } getSortLabel(field: string): string { @@ -535,9 +615,6 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { return moment(dateStr).format(format); } - isSortHovered = false; - sortWasClicked = false; - onSortMouseEnter() { this.isSortHovered = true; this.sortWasClicked = false; @@ -560,7 +637,13 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { } selectAssignment(assignment: any) { - if (!this.authService.isSignedIn()) return; + const provider = assignment.provider || "github"; + if (!this.accountsService.hasAccount(provider)) { + const errorKey = provider === "gitlab" ? "HOME.MUST-LOGIN-ASSIGNMENT-GITLAB" : "HOME.MUST-LOGIN-ASSIGNMENT-GITHUB"; + const msg = this.translateService.instant(errorKey); + this.toastService.warning(this.translateService.instant("WARNING"), msg); + return; + } this.dataService.assignment = assignment; this.dataService.groupFilter = ""; if (this.dataService.repoToLoad) { @@ -569,19 +652,80 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { } deleteAssignment(assignment: any) { - if (!this.authService.isSignedIn()) return; + if (!this.isAssignmentConnected(assignment)) return; this.databaseService.deleteAssignment(assignment.id); } - createAssignment() { - if (!this.authService.isSignedIn()) return; + createAssignment(provider?: GitProviderType, event?: MouseEvent) { + if (event) { + event.stopPropagation(); + } + const targetProvider: GitProviderType = provider || (this.filterType !== "all" ? this.filterType : this.lastUsedProvider) || "github"; + + if (!this.accountsService.hasAccount(targetProvider)) { + if (this.accountsService.isEmpty()) { + const msg = this.translateService.instant("HOME.MUST-LOGIN"); + this.toastService.warning(this.translateService.instant("WARNING"), msg); + return; + } + const errorKey = targetProvider === "gitlab" ? "HOME.MUST-LOGIN-GITLAB" : "HOME.MUST-LOGIN-GITHUB"; + const msg = this.translateService.instant(errorKey); + this.toastService.warning(this.translateService.instant("WARNING"), msg); + return; + } + if (this.isCreatingNew) return; // Prevent multiple creates + this.closeAllCreationPopovers(); + this.lastUsedProvider = targetProvider; + try { + localStorage.setItem("git4school_last_provider", targetProvider); + } catch (e) {} + let assignment = new Assignment(); assignment.id = -1; // Temporary ID for creation + assignment.provider = targetProvider; + (assignment as any).uiType = targetProvider; + (assignment as any).uiStatus = "prepared"; this.assignments.unshift(assignment); // Add to the top this.isCreatingNew = true; this.editAssignment(assignment); + this.cdr.markForCheck(); + } + + onNewAssignmentClick(event?: MouseEvent) { + if (event) { + event.stopPropagation(); + } + if (!this.canCreateAny) { + const msg = this.translateService.instant("HOME.MUST-LOGIN"); + this.toastService.warning(this.translateService.instant("WARNING"), msg); + return; + } + + /* If a specific provider filter is active, create directly with that provider */ + if (this.filterType === "github") { + this.createAssignment("github"); + return; + } + if (this.filterType === "gitlab") { + this.createAssignment("gitlab"); + return; + } + + this.createAssignment(this.lastUsedProvider); + } + + toggleProviderDropdown(event?: MouseEvent): void { + if (event) { + event.stopPropagation(); + } + this.isProviderDropdownOpen = !this.isProviderDropdownOpen; + } + + closeAllCreationPopovers(): void { + this.isProviderDropdownOpen = false; + this.cdr.markForCheck(); } trackByAssignmentId(index: number, item: any): any { @@ -589,7 +733,7 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { } editAssignment(assignment: any) { - if (!this.authService.isSignedIn()) return; + if (!this.isAssignmentConnected(assignment) && assignment.id !== -1) return; // If we were creating a new one and clicked edit on another, discard the new one if (this.isCreatingNew && assignment.id !== -1) { @@ -620,6 +764,12 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { } onAssignmentSaved(assignment: Assignment) { + if (assignment && assignment.provider) { + this.lastUsedProvider = assignment.provider; + try { + localStorage.setItem("git4school_last_provider", assignment.provider); + } catch (e) {} + } this.isCreatingNew = false; this.editingAssignmentId = null; this.loadAssignments(); diff --git a/src/app/components/home/assignment-chooser/configuration/configuration.component.html b/src/app/components/home/assignment-chooser/configuration/configuration.component.html index 6329427a..5d5fb48c 100644 --- a/src/app/components/home/assignment-chooser/configuration/configuration.component.html +++ b/src/app/components/home/assignment-chooser/configuration/configuration.component.html @@ -30,7 +30,7 @@
-
diff --git a/src/app/components/home/assignment-chooser/configuration/configuration.component.ts b/src/app/components/home/assignment-chooser/configuration/configuration.component.ts index 98628107..97f307c1 100644 --- a/src/app/components/home/assignment-chooser/configuration/configuration.component.ts +++ b/src/app/components/home/assignment-chooser/configuration/configuration.component.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core"; import { FileChooserComponent } from "@components/file-chooser/file-chooser.component"; import { Assignment } from "@models/Assignment.model"; +import { Repository } from "@models/Repository.model"; import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; import { Optional } from "@angular/core"; import { TranslateService } from "@ngx-translate/core"; @@ -78,7 +79,11 @@ export class ConfigurationComponent implements OnInit { } if (this.reposComp) { - this.assignment.repositories = this.reposComp.getFormControls.map((row) => row.value); + this.assignment.repositories = this.reposComp.getFormControls.map((row) => { + const repo = Repository.withJSON(row.value); + repo.provider = this.assignment.provider || "github"; + return repo; + }); } this.metadataModified = false; diff --git a/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.html b/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.html index 917e383f..f2fef326 100644 --- a/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.html +++ b/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.html @@ -183,7 +183,7 @@
{{ 'EDIT-REPOSITORIES.NO-REPOSITORIES' | translate }}
{{ 'EDIT-REPOSITORIES.ADD-EMPTY-REPOSITORY' | translate }} - diff --git a/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.ts b/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.ts index 7e0fe9a0..4a2841dc 100644 --- a/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.ts +++ b/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.ts @@ -1,25 +1,14 @@ -import { - ChangeDetectorRef, - Component, - OnInit, - TemplateRef, - ViewChild, -} from "@angular/core"; -import { - AbstractControl, - AsyncValidatorFn, - FormBuilder, - FormGroup, - ValidationErrors, - ValidatorFn, - Validators, -} from "@angular/forms"; +import { ChangeDetectorRef, Component, Input, OnInit, TemplateRef, ViewChild } from "@angular/core"; +import { AbstractControl, AsyncValidatorFn, FormBuilder, FormGroup, ValidationErrors, ValidatorFn, Validators } from "@angular/forms"; import { Error, Repository } from "@models/Repository.model"; +import { Assignment } from "@models/Assignment.model"; +import { GitProviderType } from "@models/Account.model"; import { TranslateService } from "@ngx-translate/core"; import { CustomModalService } from "@shared/ui/custom-modal/custom-modal.service"; import { CustomModalRef } from "@shared/ui/custom-modal/custom-modal-ref"; import { ToastService } from "@services/toast.service"; -import { AuthService } from "@services/auth.service"; +import { AccountsService } from "@services/accounts.service"; +import { GithubAuthService } from "@services/github-auth.service"; import { DataService } from "@services/data.service"; import { Observable, of, timer } from "rxjs"; import { catchError, map, switchMap, take } from "rxjs/operators"; @@ -33,20 +22,23 @@ export type SortDirection = "asc" | "desc" | ""; /** * This component lets the user edit the list of repositories of an assignment, manually by entering the URL of the repository, - * or by selecting it in the list of repositories retrieved from Github + * or by selecting it in the list of repositories retrieved from Github or GitLab */ @Component({ selector: "edit-repositories", templateUrl: "./edit-repositories.component.html", - styleUrls: [ - "../configuration.component.scss", - "./edit-repositories.component.scss", - ], + styleUrls: ["../configuration.component.scss", "./edit-repositories.component.scss"], }) -export class EditRepositoriesComponent - extends BaseTabEditConfigurationComponent - implements OnInit -{ +export class EditRepositoriesComponent extends BaseTabEditConfigurationComponent implements OnInit { + @Input() assignment?: Assignment; + + get provider(): GitProviderType { + return this.assignment?.provider || "github"; + } + + get isConnectedToProvider(): boolean { + return this.accountsService.hasAccount(this.provider); + } /** * The matrix that defines the transition relationships between the sorting modes. * @@ -78,18 +70,12 @@ export class EditRepositoriesComponent const name = group.get("name")?.value?.toLowerCase() || ""; const url = group.get("url")?.value?.toLowerCase() || ""; const tpGroup = group.get("tpGroup")?.value?.toLowerCase() || ""; - return ( - name.includes(lowerQuery) || - url.includes(lowerQuery) || - tpGroup.includes(lowerQuery) - ); + return name.includes(lowerQuery) || url.includes(lowerQuery) || tpGroup.includes(lowerQuery); }); } get existingTpGroups(): string[] { - const groups = this.getFormControls - .map((group) => group.get("tpGroup")?.value) - .filter((val) => val && val.trim() !== ""); + const groups = this.getFormControls.map((group) => group.get("tpGroup")?.value).filter((val) => val && val.trim() !== ""); return Array.from(new Set(groups)).sort(); } @@ -111,17 +97,13 @@ export class EditRepositoriesComponent } isAllSelected(): boolean { - const visibleIds = this.filteredFormControls.map((group) => - this.getFormControls.indexOf(group) - ); + const visibleIds = this.filteredFormControls.map((group) => this.getFormControls.indexOf(group)); if (visibleIds.length === 0) return false; return visibleIds.every((id) => this.selectedRepositories.has(id)); } toggleSelectAll() { - const visibleIds = this.filteredFormControls.map((group) => - this.getFormControls.indexOf(group) - ); + const visibleIds = this.filteredFormControls.map((group) => this.getFormControls.indexOf(group)); if (this.isAllSelected()) { visibleIds.forEach((id) => this.selectedRepositories.delete(id)); } else { @@ -137,9 +119,7 @@ export class EditRepositoriesComponent deleteSelected() { if (this.selectedRepositories.size === 0) return; - const indicesToDelete = Array.from(this.selectedRepositories).sort( - (a, b) => b - a - ); + const indicesToDelete = Array.from(this.selectedRepositories).sort((a, b) => b - a); indicesToDelete.forEach((index) => this.deleteRow(index)); this.cancelSelection(); @@ -149,19 +129,22 @@ export class EditRepositoriesComponent * EditRepositoriesComponent constructor * @param fb The service to build formGroups * @param cdref - * @param authService The service managing authentication - * @param modalService The service to open a modal - * @param translateService The service for the localization - * @param dataService The service to manage the application data at runtime + * @param githubAuthService The service managing authentication + * @param formBuilder Helper service for building forms + * @param dataService The service managing application data + * @param modalService The modal service + * @param translateService The translation service + * @param toastService The toast notification service */ constructor( protected fb: FormBuilder, protected cdref: ChangeDetectorRef, - public authService: AuthService, + public accountsService: AccountsService, + public githubAuthService: GithubAuthService, private modalService: CustomModalService, private translateService: TranslateService, private toastService: ToastService, - private dataService: DataService + private dataService: DataService, ) { super(fb, cdref); } @@ -184,17 +167,13 @@ export class EditRepositoriesComponent } /** - * Opens the modal to add one or several repositories from a list retrieved from Github. + * Opens the modal to add one or several repositories from a list retrieved from Github or GitLab. * If the modal is closed with the "Add" button, all the new selected repositories are saved in the assignment */ openAddRepositoriesModal() { - let modalReference: CustomModalRef = this.modalService.open( - ModalAddRepositoriesComponent, - { size: "lg" } - ); - modalReference.componentInstance.repoList = this.getFormControls.map( - (row) => Repository.withJSON(row.value) - ); + let modalReference: CustomModalRef = this.modalService.open(ModalAddRepositoriesComponent, { size: "lg" }); + modalReference.componentInstance.repoList = this.getFormControls.map((row) => Repository.withJSON(row.value)); + modalReference.componentInstance.provider = this.provider; modalReference.result.then( (result) => { @@ -203,15 +182,16 @@ export class EditRepositoriesComponent (repo1) => !this.getFormControls .map((row) => Repository.withJSON(row.value)) - .some((repo2, index, array) => Repository.isEqual(repo1, repo2)) + .some((repo2, index, array) => Repository.isEqual(repo1, repo2)), ); repoToadd.forEach((repo) => { + repo.provider = this.provider; this.addRow(repo); }); this.modify(); } }, - (error) => {} + (error) => {}, ); } @@ -232,9 +212,7 @@ export class EditRepositoriesComponent if (!errors) { return ""; } - return errors - .map((err) => this.translateService.instant("ERROR-MESSAGE-" + err.type)) - .join(". "); + return errors.map((err) => this.translateService.instant("ERROR-MESSAGE-" + err.type)).join(". "); } /** @@ -256,9 +234,7 @@ export class EditRepositoriesComponent * @param property The property to sort the table with */ onSort(property: string) { - this.lastPropertySorted === property - ? this.rotate() - : (this.nameDirection = "asc"); + this.lastPropertySorted === property ? this.rotate() : (this.nameDirection = "asc"); this.sort(property); this.lastPropertySorted = property; @@ -302,6 +278,7 @@ export class EditRepositoriesComponent tpGroup: [data?.tpGroup], errors: [data ? data.errors : []], avatarUrl: [avatarUrl], + provider: [data?.provider || this.provider], isEditable: false, isInvalid: false, save: {}, @@ -314,13 +291,9 @@ export class EditRepositoriesComponent private repoAlreadyAddedValidator(): ValidatorFn { return (urlControl: AbstractControl): ValidationErrors | null => { let doesRepoAlreadyAdded = this.getFormControls.some( - (repo2, index, array) => - urlControl.value === repo2.get("url").value && - !Object.is(urlControl, repo2.get("url")) + (repo2, index, array) => urlControl.value === repo2.get("url").value && !Object.is(urlControl, repo2.get("url")), ); - return urlControl.value && doesRepoAlreadyAdded - ? { repoAlreadyAdded: true } - : null; + return urlControl.value && doesRepoAlreadyAdded ? { repoAlreadyAdded: true } : null; }; } @@ -328,39 +301,32 @@ export class EditRepositoriesComponent * A validator checking if the authenticated user has access to the specified repository (and if it exists) */ private accessToRepoValidator(initialUrl?: string): AsyncValidatorFn { - return ( - urlControl: AbstractControl - ): Observable => { + return (urlControl: AbstractControl): Observable => { if (!urlControl.value || urlControl.value === initialUrl) { return timer(10).pipe( map(() => null), - take(1) + take(1), ); } else { return timer(1000).pipe( - switchMap(() => this.authService.verifyUserAccess(urlControl.value)), + switchMap(() => this.accountsService.getDataService(this.provider).verifyUserAccess(urlControl.value)), map((res) => { if (urlControl.parent && urlControl.parent.get("avatarUrl")) { - urlControl.parent - .get("avatarUrl") - .setValue(res?.owner?.avatar_url || null, { emitEvent: false }); + const avatar = res?.avatar_url || res?.owner?.avatar_url || null; + urlControl.parent.get("avatarUrl").setValue(avatar, { emitEvent: false }); } return null; }), catchError((err) => { if (urlControl.parent && urlControl.parent.get("avatarUrl")) { - urlControl.parent - .get("avatarUrl") - .setValue(null, { emitEvent: false }); + urlControl.parent.get("avatarUrl").setValue(null, { emitEvent: false }); } const title = this.translateService.instant("ERROR"); - const msg = this.translateService.instant( - "ERROR-MESSAGE-NO-ACCESS" - ); + const msg = this.translateService.instant("ERROR-MESSAGE-NO-ACCESS"); this.toastService.error(title, msg); return of({ noAccess: true }); }), - take(1) + take(1), ); } }; @@ -383,11 +349,7 @@ export class EditRepositoriesComponent this.initForm(this.datas); } else { let sortFactor = this.nameDirection === "asc" ? 1 : -1; - this.formGroups = [...this.formGroups].sort( - (a, b) => - sortFactor * - a.get(property).value?.localeCompare(b.get(property).value) - ); + this.formGroups = [...this.formGroups].sort((a, b) => sortFactor * a.get(property).value?.localeCompare(b.get(property).value)); } } } diff --git a/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.ts b/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.ts index fbad8ba4..d5d455ef 100644 --- a/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.ts +++ b/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.ts @@ -1,13 +1,5 @@ -import { - AfterViewInit, - Component, - ElementRef, - Input, - NgZone, - OnDestroy, - OnInit, - ViewChild, -} from "@angular/core"; +import { AfterViewInit, Component, ElementRef, Input, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { GitProviderType } from "@models/Account.model"; import { Repository } from "@models/Repository.model"; import { CustomModalRef } from "@shared/ui/custom-modal/custom-modal-ref"; import { CommitsService } from "@services/commits.service"; @@ -20,11 +12,10 @@ import { debounceTime, map } from "rxjs/operators"; templateUrl: "./modal-add-repositories.component.html", styleUrls: ["./modal-add-repositories.component.scss"], }) -export class ModalAddRepositoriesComponent - implements OnInit, OnDestroy, AfterViewInit -{ +export class ModalAddRepositoriesComponent implements OnInit, OnDestroy, AfterViewInit { @ViewChild("reposTable", { read: ElementRef }) datatable: ElementRef; @Input() repoList: Repository[]; + @Input() provider: GitProviderType = "github"; rows: Repository[]; nameMatches: Repository[] = []; contentMatches: Repository[] = []; @@ -43,17 +34,10 @@ export class ModalAddRepositoriesComponent private searchFilterChanged: Subject; private searchFilter; - constructor( - public activeModal: CustomModalRef, - private commitsService: CommitsService, - private ngZone: NgZone - ) {} + constructor(public activeModal: CustomModalRef, private commitsService: CommitsService, private ngZone: NgZone) {} get hasGroupedMatches(): boolean { - return ( - Boolean(this.searchFilter) && - (this.contentMatches.length > 0 || this.nameMatches.length > 0) - ); + return Boolean(this.searchFilter) && (this.contentMatches.length > 0 || this.nameMatches.length > 0); } toggleNameMatches() { @@ -73,19 +57,17 @@ export class ModalAddRepositoriesComponent private getTargetSourceUrl(filter: string): string | undefined { if (!filter) return undefined; const clean = filter.trim(); - const urlMatch = clean.match( - /^(?:https?:\/\/github\.com\/|git@github\.com:)?([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git|\/)?$/i - ); + const urlMatch = clean.match(/^(?:https?:\/\/[^/]+\/|git@[^:]+:)?([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git|\/)?$/i); if (urlMatch) { - return `https://github.com/${urlMatch[1]}/${urlMatch[2]}`; + const defaultHost = this.provider === "gitlab" ? "gitlab.com" : "github.com"; + const hostMatch = clean.match(/^(?:https?:\/\/([^/]+)\/|git@([^:]+):)/i); + const host = hostMatch ? hostMatch[1] || hostMatch[2] : defaultHost; + return `https://${host}/${urlMatch[1]}/${urlMatch[2]}`; } return undefined; } - organizeHierarchy( - repositories: Repository[], - targetSourceUrl?: string - ): Repository[] { + organizeHierarchy(repositories: Repository[], targetSourceUrl?: string): Repository[] { if (!repositories || repositories.length === 0) { return []; } @@ -101,12 +83,8 @@ export class ModalAddRepositoriesComponent const rootRepos: Repository[] = []; const targetLower = targetSourceUrl ? targetSourceUrl.toLowerCase() : null; const targetRepo = targetLower ? urlMap.get(targetLower) : null; - const { owner } = targetSourceUrl - ? this.getSearchTerms(targetSourceUrl) - : { owner: undefined }; - const targetCore = targetRepo - ? Utils.extractAssignmentCore(targetRepo.name, owner) - : ""; + const { owner } = targetSourceUrl ? this.getSearchTerms(targetSourceUrl) : { owner: undefined }; + const targetCore = targetRepo ? Utils.extractAssignmentCore(targetRepo.name, owner) : ""; for (const repo of repositories) { const pUrl = repo.parentUrl ? repo.parentUrl.toLowerCase() : null; @@ -121,8 +99,7 @@ export class ModalAddRepositoriesComponent targetRepo && repo.url.toLowerCase() !== targetLower && targetCore && - (repo.name.toLowerCase().includes(targetCore) || - Utils.extractAssignmentCore(repo.name, owner).includes(targetCore)) + (repo.name.toLowerCase().includes(targetCore) || Utils.extractAssignmentCore(repo.name, owner).includes(targetCore)) ) { // Child fork belonging to target assignment (e.g. GitHub Classroom student repo created from template) if (!parentToChildren.has(targetLower)) { @@ -136,9 +113,7 @@ export class ModalAddRepositoriesComponent // If targetSourceUrl is provided, ensure target repository is at index 0 if (targetLower) { - const targetIdx = rootRepos.findIndex( - (r) => r.url && r.url.toLowerCase() === targetLower - ); + const targetIdx = rootRepos.findIndex((r) => r.url && r.url.toLowerCase() === targetLower); if (targetIdx > 0) { const [targetR] = rootRepos.splice(targetIdx, 1); rootRepos.unshift(targetR); @@ -170,9 +145,7 @@ export class ModalAddRepositoriesComponent if (!filter) return { name: "" }; const clean = filter.trim(); // 1. URL pattern (e.g. https://github.com/UE-TOAW/repo or UE-TOAW/repo) - const urlMatch = clean.match( - /^(?:https?:\/\/github\.com\/|git@github\.com:)?([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git|\/)?$/i - ); + const urlMatch = clean.match(/^(?:https?:\/\/[^/]+\/|git@[^:]+:)?([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git|\/)?$/i); if (urlMatch) { return { owner: urlMatch[1].toLowerCase(), @@ -180,9 +153,7 @@ export class ModalAddRepositoriesComponent }; } // 2. Org URL or single token (e.g. https://github.com/UE-TOAW or UE-TOAW) - const singleMatch = clean.match( - /^(?:https?:\/\/github\.com\/|git@github\.com:)?([a-zA-Z0-9_.-]+)\/?$/i - ); + const singleMatch = clean.match(/^(?:https?:\/\/[^/]+\/|git@[^:]+:)?([a-zA-Z0-9_.-]+)\/?$/i); if (singleMatch) { return { name: singleMatch[1].toLowerCase() }; } @@ -211,12 +182,7 @@ export class ModalAddRepositoriesComponent if (cleanName && repoUrl.includes(cleanName)) { return true; } - if ( - coreSearch && - (repoName.includes(coreSearch) || - coreRepo.includes(coreSearch) || - coreSearch.includes(coreRepo)) - ) { + if (coreSearch && (repoName.includes(coreSearch) || coreRepo.includes(coreSearch) || coreSearch.includes(coreRepo))) { return true; } return false; @@ -235,10 +201,7 @@ export class ModalAddRepositoriesComponent const cleanName = name.replace(/[-_]+$/, ""); const coreSearch = Utils.extractAssignmentCore(name, owner); const desc = (repo.description || "").toLowerCase(); - return ( - (Boolean(cleanName) && desc.includes(cleanName)) || - (Boolean(coreSearch) && desc.includes(coreSearch)) - ); + return (Boolean(cleanName) && desc.includes(cleanName)) || (Boolean(coreSearch) && desc.includes(coreSearch)); } private updateMatchesGrouping() { @@ -270,20 +233,13 @@ export class ModalAddRepositoriesComponent for (const group of groups) { const rootNameMatch = this.isNameMatch(group.root, this.searchFilter); - const childNameMatch = group.children.some((c) => - this.isNameMatch(c, this.searchFilter) - ); + const childNameMatch = group.children.some((c) => this.isNameMatch(c, this.searchFilter)); if (rootNameMatch || childNameMatch) { nameGroupRows.push(group.root, ...group.children); } else { - const rootContentMatch = this.isContentMatch( - group.root, - this.searchFilter - ); - const childContentMatch = group.children.some((c) => - this.isContentMatch(c, this.searchFilter) - ); + const rootContentMatch = this.isContentMatch(group.root, this.searchFilter); + const childContentMatch = group.children.some((c) => this.isContentMatch(c, this.searchFilter)); if (isGlobalSearch || rootContentMatch || childContentMatch) { contentGroupRows.push(group.root, ...group.children); @@ -296,10 +252,7 @@ export class ModalAddRepositoriesComponent } private updateResults(repositories: Repository[], isFirstPage = false) { - const filterAlreadyInAssignment = (repo: Repository) => - repo && - repo.url && - !this.repoList.some((r) => Repository.isEqual(r, repo)); + const filterAlreadyInAssignment = (repo: Repository) => repo && repo.url && !this.repoList.some((r) => Repository.isEqual(r, repo)); const targetUrl = this.getTargetSourceUrl(this.searchFilter); @@ -315,20 +268,13 @@ export class ModalAddRepositoriesComponent this.rows = this.organizeHierarchy(filtered, targetUrl); } else { const seenUrls = new Set(this.rows.map((r) => r.url)); - const newRepos = repositories.filter( - (repo) => filterAlreadyInAssignment(repo) && !seenUrls.has(repo.url) - ); - this.rows = this.organizeHierarchy( - [...this.rows, ...newRepos], - targetUrl - ); + const newRepos = repositories.filter((repo) => filterAlreadyInAssignment(repo) && !seenUrls.has(repo.url)); + this.rows = this.organizeHierarchy([...this.rows, ...newRepos], targetUrl); } if (!this.searchFilter) { const cachedUrls = new Set(this.allUserRepositories.map((r) => r.url)); - const newForCache = repositories.filter( - (repo) => repo && repo.url && !cachedUrls.has(repo.url) - ); + const newForCache = repositories.filter((repo) => repo && repo.url && !cachedUrls.has(repo.url)); this.allUserRepositories = [...this.allUserRepositories, ...newForCache]; } @@ -342,7 +288,7 @@ export class ModalAddRepositoriesComponent repositories: Repository[]; cursor?: string; }>, - isFirstPage = false + isFirstPage = false, ): Subscription { return response .pipe( @@ -350,43 +296,26 @@ export class ModalAddRepositoriesComponent this.done = res.completed; this.cursor = res.cursor; return res.repositories; - }) + }), ) .subscribe((repositories) => { this.updateResults(repositories, isFirstPage); }); } - private updateResultsWithAuthenticatedUser( - cursor?: string, - isFirstPage = false - ) { - this.processIntermediateResponse( - this.commitsService.getRepositoriesByAuthenticatedUser(cursor), - isFirstPage - ); + private updateResultsWithAuthenticatedUser(cursor?: string, isFirstPage = false) { + this.processIntermediateResponse(this.commitsService.getRepositoriesByAuthenticatedUser(cursor, 100, this.provider), isFirstPage); } - private updateResultsWithSearchFilter( - searchFilter: string, - cursor?: string, - isFirstPage = false - ) { - this.processIntermediateResponse( - this.commitsService.getRepositoriesBySearch(searchFilter, cursor), - isFirstPage - ); + private updateResultsWithSearchFilter(searchFilter: string, cursor?: string, isFirstPage = false) { + this.processIntermediateResponse(this.commitsService.getRepositoriesBySearch(searchFilter, cursor, 100, this.provider), isFirstPage); } private loadResults() { this.loading = true; const isFirstPage = !this.cursor; if (this.searchFilter) { - this.updateResultsWithSearchFilter( - this.searchFilter, - this.cursor, - isFirstPage - ); + this.updateResultsWithSearchFilter(this.searchFilter, this.cursor, isFirstPage); } else { this.updateResultsWithAuthenticatedUser(this.cursor, isFirstPage); } @@ -398,8 +327,7 @@ export class ModalAddRepositoriesComponent onScroll(event: Event) { const target = event.target as HTMLElement; - const endOfScrolling = - target.scrollHeight - target.scrollTop <= target.clientHeight + 50; + const endOfScrolling = target.scrollHeight - target.scrollTop <= target.clientHeight + 50; if (!this.loading && !this.done && endOfScrolling) { this.ngZone.run(() => { @@ -412,6 +340,7 @@ export class ModalAddRepositoriesComponent this.selected.forEach((repo) => { repo.tpGroup = this.tpGroup; repo.name = ""; + repo.provider = this.provider; }); this.activeModal.close(this.selected); } @@ -439,9 +368,7 @@ export class ModalAddRepositoriesComponent getChildForks(parentRepo: Repository): Repository[] { if (!parentRepo || !parentRepo.url) return []; const parentUrlLower = parentRepo.url.toLowerCase(); - return this.rows.filter( - (r) => r.isChildFork && r.parentUrl && r.parentUrl.toLowerCase() === parentUrlLower - ); + return this.rows.filter((r) => r.isChildFork && r.parentUrl && r.parentUrl.toLowerCase() === parentUrlLower); } hasChildForks(parentRepo: Repository): boolean { @@ -513,19 +440,13 @@ export class ModalAddRepositoriesComponent this.cursor = undefined; this.done = false; const seenUrls = new Set(this.repoList.map((r) => r.url)); - const filtered = this.allUserRepositories.filter( - (r) => !seenUrls.has(r.url) - ); + const filtered = this.allUserRepositories.filter((r) => !seenUrls.has(r.url)); this.rows = this.organizeHierarchy(filtered); this.applySort(); this.loading = false; } else { // If a full repository URL is typed/pasted, reset sorting so target repo stays in first position - if ( - /^(?:https?:\/\/github\.com\/|git@github\.com:)/i.test( - this.searchFilter - ) - ) { + if (/^(?:https?:\/\/[^/]+\/|git@[^:]+:)/i.test(this.searchFilter)) { this.sortBy = ""; } // Immediate client-side filter of cached repositories for instant feedback @@ -535,10 +456,7 @@ export class ModalAddRepositoriesComponent const seenUrls = new Set(this.repoList.map((r) => r.url)); const targetUrl = this.getTargetSourceUrl(this.searchFilter); const filtered = this.allUserRepositories.filter( - (r) => - !seenUrls.has(r.url) && - (this.isNameMatch(r, this.searchFilter) || - this.isContentMatch(r, this.searchFilter)) + (r) => !seenUrls.has(r.url) && (this.isNameMatch(r, this.searchFilter) || this.isContentMatch(r, this.searchFilter)), ); this.rows = this.organizeHierarchy(filtered, targetUrl); this.applySort(); @@ -628,14 +546,12 @@ export class ModalAddRepositoriesComponent this.done = false; this.searchFilterChanged = new Subject(); this.searchFilter = ""; - this.searchSubscription = this.searchFilterChanged - .pipe(debounceTime(600)) - .subscribe((searchFilter) => { - if (searchFilter) { - this.cursor = undefined; - this.loadResults(); - } - }); + this.searchSubscription = this.searchFilterChanged.pipe(debounceTime(600)).subscribe((searchFilter) => { + if (searchFilter) { + this.cursor = undefined; + this.loadResults(); + } + }); } ngOnInit() { @@ -646,10 +562,7 @@ export class ModalAddRepositoriesComponent ngAfterViewInit() { this.ngZone.runOutsideAngular(() => { if (this.datatable && this.datatable.nativeElement) { - this.datatable.nativeElement.addEventListener( - "scroll", - this.onScroll.bind(this) - ); + this.datatable.nativeElement.addEventListener("scroll", this.onScroll.bind(this)); } }); } @@ -661,10 +574,7 @@ export class ModalAddRepositoriesComponent ngOnDestroy(): void { this.searchSubscription.unsubscribe(); if (this.datatable && this.datatable.nativeElement) { - this.datatable.nativeElement.removeEventListener( - "scroll", - this.onScroll.bind(this) - ); + this.datatable.nativeElement.removeEventListener("scroll", this.onScroll.bind(this)); } } } diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts index c4513bdf..3bfdce5e 100644 --- a/src/app/components/home/home.component.ts +++ b/src/app/components/home/home.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from "@angular/core"; -import { AuthService } from "@services/auth.service"; +import { GithubAuthService } from "@services/github-auth.service"; import { CustomModalService } from "@shared/ui/custom-modal/custom-modal.service"; import { AccountsService } from "@services/accounts.service"; import { AddAccountModalComponent } from "../nav-layouts/sidebar-settings/accounts/add-account-modal/add-account-modal.component"; @@ -19,11 +19,15 @@ export class HomeComponent implements OnInit { /** * HomeComponent constructor - * @param authService The service managing authentication + * @param githubAuthService The service managing authentication * @param accountsService The service managing connected accounts * @param customModalService The modal service */ - constructor(public authService: AuthService, public accountsService: AccountsService, private customModalService: CustomModalService) {} + constructor( + public githubAuthService: GithubAuthService, + public accountsService: AccountsService, + private customModalService: CustomModalService, + ) {} ngOnInit() { window.addEventListener("git4school:open-accounts-modal", () => this.onAddAccount()); @@ -34,7 +38,7 @@ export class HomeComponent implements OnInit { } async onSignInGithub() { - await this.authService.signIn(); + await this.githubAuthService.signIn(); } scroll(el: HTMLElement) { diff --git a/src/app/components/nav-items/auth-lang-nav-item/auth-lang-nav-item.component.html b/src/app/components/nav-items/auth-lang-nav-item/auth-lang-nav-item.component.html index 7bf047df..edbe6e50 100644 --- a/src/app/components/nav-items/auth-lang-nav-item/auth-lang-nav-item.component.html +++ b/src/app/components/nav-items/auth-lang-nav-item/auth-lang-nav-item.component.html @@ -16,7 +16,7 @@ - @@ -149,141 +148,20 @@