From f66fcc39318fd54ca59838abd7dd14cf4c86ecb0 Mon Sep 17 00:00:00 2001 From: Mika Pons Date: Thu, 17 Sep 2026 11:33:05 +0200 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20Add=20GitLab=20OAuth=202.0=20PK?= =?UTF-8?q?CE,=20Strategy=20Pattern=20auth=20and=20ADR-0006?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...on-multi-fournisseurs-pattern-strategie.md | 63 ++++ src/app/app-routing.module.ts | 2 + src/app/app.component.ts | 14 +- src/app/app.module.ts | 6 +- .../gitlab-callback.component.html | 11 + .../gitlab-callback.component.scss | 40 +++ .../gitlab-callback.component.spec.ts | 75 +++++ .../gitlab-callback.component.ts | 49 +++ .../assignment-chooser.component.html | 22 +- .../assignment-chooser.component.ts | 12 +- .../edit-repositories.component.html | 2 +- .../edit-repositories.component.ts | 123 ++----- src/app/components/home/home.component.ts | 12 +- .../auth-lang-nav-item.component.html | 4 +- .../auth-lang-nav-item.component.ts | 12 +- .../app-nav-layout.component.ts | 4 +- .../accounts/accounts.component.html | 8 +- .../add-account-modal.component.html | 270 ++++++++------- .../add-account-modal.component.scss | 6 +- .../add-account-modal.component.spec.ts | 85 ++++- .../add-account-modal.component.ts | 94 ++++-- .../sidebar-settings.component.html | 2 +- .../sidebar-settings.component.ts | 12 +- src/app/guards/auth.guard.ts | 28 +- src/app/guards/auth.guard.ts.orig | 38 --- src/app/guards/auth.guard.ts.rej | 17 - src/app/models/GitAuthProvider.model.ts | 15 + src/app/services/accounts.service.spec.ts | 134 +++++++- src/app/services/accounts.service.ts | 87 +++-- src/app/services/commits.service.ts | 8 +- ...ce.spec.ts => github-auth.service.spec.ts} | 36 +- ...auth.service.ts => github-auth.service.ts} | 84 +++-- src/app/services/gitlab-auth.service.spec.ts | 97 ++++++ src/app/services/gitlab-auth.service.ts | 317 ++++++++++++++++++ .../shared/ui/dev-bar/dev-bar.component.ts | 4 +- src/assets/additional-docs/CONTRIBUTING.md | 4 +- src/assets/i18n/en.json | 12 +- src/assets/i18n/fr.json | 12 +- src/assets/i18n/ru.json | 12 +- src/environments/environment.prod.ts | 6 + src/environments/environment.ts | 6 + src/styles/_variables.scss | 8 +- 42 files changed, 1342 insertions(+), 511 deletions(-) create mode 100644 docs/adr/0006-authentification-multi-fournisseurs-pattern-strategie.md create mode 100644 src/app/components/auth-callback/gitlab-callback.component.html create mode 100644 src/app/components/auth-callback/gitlab-callback.component.scss create mode 100644 src/app/components/auth-callback/gitlab-callback.component.spec.ts create mode 100644 src/app/components/auth-callback/gitlab-callback.component.ts delete mode 100644 src/app/guards/auth.guard.ts.orig delete mode 100644 src/app/guards/auth.guard.ts.rej create mode 100644 src/app/models/GitAuthProvider.model.ts rename src/app/services/{auth.service.spec.ts => github-auth.service.spec.ts} (72%) rename src/app/services/{auth.service.ts => github-auth.service.ts} (74%) create mode 100644 src/app/services/gitlab-auth.service.spec.ts create mode 100644 src/app/services/gitlab-auth.service.ts 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/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..3cb5e0a3 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 { 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"; @@ -22,14 +22,14 @@ export class AppComponent implements OnInit, OnDestroy { 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, @@ -42,9 +42,9 @@ export class AppComponent implements OnInit, OnDestroy { */ ngOnInit(): void { Chart.pluginService.unregister(ChartDataLabels); - this.authService.loading = false; - if (!this.authService.isSignedIn()) { - this.authService.reauthenticate(); + this.githubAuthService.loading = false; + if (!this.githubAuthService.isSignedIn()) { + this.githubAuthService.reauthenticate(); } } @@ -53,6 +53,6 @@ export class AppComponent implements OnInit, OnDestroy { * Disconnects the user */ ngOnDestroy(): void { - this.authService.signOut(); + this.githubAuthService.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..242cc03c --- /dev/null +++ b/src/app/components/auth-callback/gitlab-callback.component.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed } 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; + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should postMessage to opener and close if opener exists", () => { + 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( + { + type: "GITLAB_OAUTH_CALLBACK", + code: "test-code", + state: "test-state", + error: null, + errorDescription: null, + }, + window.location.origin, + ); + expect(closeSpy).toHaveBeenCalled(); + + delete (window as any).opener; + }); + + it("should navigate to home if no window.opener", () => { + delete (window as any).opener; + component.ngOnInit(); + 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..7123a6b4 100644 --- a/src/app/components/home/assignment-chooser/assignment-chooser.component.html +++ b/src/app/components/home/assignment-chooser/assignment-chooser.component.html @@ -235,8 +235,8 @@
{{ 'HOME.FILTER.YEAR' | translate }}
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..ba783f83 100644 --- a/src/app/components/home/assignment-chooser/assignment-chooser.component.ts +++ b/src/app/components/home/assignment-chooser/assignment-chooser.component.ts @@ -3,7 +3,7 @@ import { Router } from "@angular/router"; import { Assignment } from "@models/Assignment.model"; import { TranslateService } from "@ngx-translate/core"; import { AssignmentsService } from "@services/assignments.service"; -import { AuthService } from "@services/auth.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"; @@ -202,7 +202,7 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { private databaseService: DatabaseService, private dataService: DataService, private router: Router, - public authService: AuthService, + public githubAuthService: GithubAuthService, private translateService: TranslateService, private toastService: ToastService, private assignmentsService: AssignmentsService, @@ -560,7 +560,7 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { } selectAssignment(assignment: any) { - if (!this.authService.isSignedIn()) return; + if (!this.githubAuthService.isSignedIn()) return; this.dataService.assignment = assignment; this.dataService.groupFilter = ""; if (this.dataService.repoToLoad) { @@ -569,12 +569,12 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { } deleteAssignment(assignment: any) { - if (!this.authService.isSignedIn()) return; + if (!this.githubAuthService.isSignedIn()) return; this.databaseService.deleteAssignment(assignment.id); } createAssignment() { - if (!this.authService.isSignedIn()) return; + if (!this.githubAuthService.isSignedIn()) return; if (this.isCreatingNew) return; // Prevent multiple creates let assignment = new Assignment(); @@ -589,7 +589,7 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { } editAssignment(assignment: any) { - if (!this.authService.isSignedIn()) return; + if (!this.githubAuthService.isSignedIn()) return; // If we were creating a new one and clicked edit on another, discard the new one if (this.isCreatingNew && assignment.id !== -1) { 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..43ad4510 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..bc203a69 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,11 @@ -import { - ChangeDetectorRef, - Component, - OnInit, - TemplateRef, - ViewChild, -} from "@angular/core"; -import { - AbstractControl, - AsyncValidatorFn, - FormBuilder, - FormGroup, - ValidationErrors, - ValidatorFn, - Validators, -} from "@angular/forms"; +import { ChangeDetectorRef, Component, 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 { 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 { 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"; @@ -38,15 +24,9 @@ export type SortDirection = "asc" | "desc" | ""; @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 { /** * The matrix that defines the transition relationships between the sorting modes. * @@ -78,18 +58,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 +85,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 +107,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 +117,21 @@ 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 githubAuthService: GithubAuthService, private modalService: CustomModalService, private translateService: TranslateService, private toastService: ToastService, - private dataService: DataService + private dataService: DataService, ) { super(fb, cdref); } @@ -188,13 +158,8 @@ export class EditRepositoriesComponent * 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.result.then( (result) => { @@ -203,7 +168,7 @@ 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) => { this.addRow(repo); @@ -211,7 +176,7 @@ export class EditRepositoriesComponent this.modify(); } }, - (error) => {} + (error) => {}, ); } @@ -232,9 +197,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 +219,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; @@ -314,13 +275,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 +285,31 @@ 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.githubAuthService.verifyUserAccess(urlControl.value)), map((res) => { if (urlControl.parent && urlControl.parent.get("avatarUrl")) { - urlControl.parent - .get("avatarUrl") - .setValue(res?.owner?.avatar_url || null, { emitEvent: false }); + urlControl.parent.get("avatarUrl").setValue(res?.owner?.avatar_url || null, { 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 +332,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/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 @@
+ (mouseleave)="hoveredAssignment = null" [class.disabled]="!isAssignmentConnected(assignment) || assignment.id === -1" + (click)="isAssignmentConnected(assignment) && assignment.id !== -1 && toggleSelection(assignment.id); $event.stopPropagation()">
@@ -394,19 +485,19 @@
{{ '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 ba783f83..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 { 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 githubAuthService: GithubAuthService, - 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.githubAuthService.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.githubAuthService.isSignedIn()) return; + if (!this.isAssignmentConnected(assignment)) return; this.databaseService.deleteAssignment(assignment.id); } - createAssignment() { - if (!this.githubAuthService.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.githubAuthService.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 43ad4510..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 bc203a69..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,10 +1,13 @@ -import { ChangeDetectorRef, Component, OnInit, TemplateRef, ViewChild } from "@angular/core"; +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 { AccountsService } from "@services/accounts.service"; import { GithubAuthService } from "@services/github-auth.service"; import { DataService } from "@services/data.service"; import { Observable, of, timer } from "rxjs"; @@ -19,7 +22,7 @@ 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", @@ -27,6 +30,15 @@ export type SortDirection = "asc" | "desc" | ""; styleUrls: ["../configuration.component.scss", "./edit-repositories.component.scss"], }) 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. * @@ -127,6 +139,7 @@ export class EditRepositoriesComponent extends BaseTabEditConfigurationComponent constructor( protected fb: FormBuilder, protected cdref: ChangeDetectorRef, + public accountsService: AccountsService, public githubAuthService: GithubAuthService, private modalService: CustomModalService, private translateService: TranslateService, @@ -154,12 +167,13 @@ export class EditRepositoriesComponent extends BaseTabEditConfigurationComponent } /** - * 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)); + modalReference.componentInstance.provider = this.provider; modalReference.result.then( (result) => { @@ -171,6 +185,7 @@ export class EditRepositoriesComponent extends BaseTabEditConfigurationComponent .some((repo2, index, array) => Repository.isEqual(repo1, repo2)), ); repoToadd.forEach((repo) => { + repo.provider = this.provider; this.addRow(repo); }); this.modify(); @@ -263,6 +278,7 @@ export class EditRepositoriesComponent extends BaseTabEditConfigurationComponent tpGroup: [data?.tpGroup], errors: [data ? data.errors : []], avatarUrl: [avatarUrl], + provider: [data?.provider || this.provider], isEditable: false, isInvalid: false, save: {}, @@ -293,10 +309,11 @@ export class EditRepositoriesComponent extends BaseTabEditConfigurationComponent ); } else { return timer(1000).pipe( - switchMap(() => this.githubAuthService.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; }), 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/models/Assignment.model.ts b/src/app/models/Assignment.model.ts index 076dfc7f..e95993e4 100644 --- a/src/app/models/Assignment.model.ts +++ b/src/app/models/Assignment.model.ts @@ -4,6 +4,7 @@ import { Repository } from "@models/Repository.model"; import { Session } from "@models/Session.model"; import { NgbTimeStruct } from "@ng-bootstrap/ng-bootstrap"; import { Type } from "class-transformer"; +import { GitProviderType } from "@models/GitAuthProvider.model"; /** * A practical course, with sessions, questions and @@ -12,7 +13,15 @@ import { Type } from "class-transformer"; export class Assignment { id: number; uiStatus?: "prepared" | "ongoing" | "finished" | "default"; - uiType?: "github" | "gitlab"; + provider: GitProviderType = "github"; + + get uiType(): GitProviderType { + return this.provider || "github"; + } + + set uiType(value: GitProviderType) { + this.provider = value || "github"; + } @Type(() => Metadata) metadata: Metadata; diff --git a/src/app/models/Commit.model.ts b/src/app/models/Commit.model.ts index 959ff3d2..15de9705 100644 --- a/src/app/models/Commit.model.ts +++ b/src/app/models/Commit.model.ts @@ -119,6 +119,20 @@ export class Commit { ); } + /** + * Initialize a Commit from the GitLab REST API JSON object + * @param json The GitLab REST JSON node + * @returns A commit + */ + static withGitlabJSON(json): Commit { + return new Commit( + json.message || json.title || "", + json.author_name || "Unknown", + json.committed_date ? new Date(json.committed_date) : new Date(json.authored_date || Date.now()), + json.web_url || "", + ); + } + /** * Updates the isEnSeance variable * @param startDate The date before which commits are not processed diff --git a/src/app/models/GitAuthProvider.model.ts b/src/app/models/GitAuthProvider.model.ts index 7e31ab0c..7723315b 100644 --- a/src/app/models/GitAuthProvider.model.ts +++ b/src/app/models/GitAuthProvider.model.ts @@ -1,6 +1,8 @@ import { Observable } from "rxjs"; import { Account, GitProviderType } from "./Account.model"; +export { GitProviderType } from "./Account.model"; + export interface GitAuthProvider { readonly provider: GitProviderType; readonly name: string; diff --git a/src/app/models/GitDataService.model.ts b/src/app/models/GitDataService.model.ts new file mode 100644 index 00000000..97805beb --- /dev/null +++ b/src/app/models/GitDataService.model.ts @@ -0,0 +1,36 @@ +import { Observable } from "rxjs"; +import { GitProviderType } from "@models/Account.model"; +import { Repository } from "@models/Repository.model"; + +export interface GitDataSearchResult { + completed: boolean; + repositories: Repository[]; + cursor?: string; +} + +/** + * Interface representing a Git data provider service (GitHub, GitLab, etc.) + */ +export interface GitDataService { + readonly provider: GitProviderType; + + /** + * Retrieves repository metadata (README.md, IDENTITY.json) and commits for a list of repositories + */ + getRepositories(repoTab: Repository[], startDate?: string, endDate?: string): Observable; + + /** + * Fetches repositories accessible by or belonging to the authenticated user + */ + getRepositoriesByAuthenticatedUser(cursor?: string, pageLimit?: number): Observable; + + /** + * Searches repositories matching the search filter (search keyword, org/group name, repo path) + */ + getRepositoriesBySearch(searchFilter: string, cursor?: string, pageLimit?: number): Observable; + + /** + * Verifies that the authenticated user has access to the specified repository URL + */ + verifyUserAccess(repoUrl: string): Observable; +} diff --git a/src/app/models/Repository.model.ts b/src/app/models/Repository.model.ts index 6f184af0..84c66bff 100644 --- a/src/app/models/Repository.model.ts +++ b/src/app/models/Repository.model.ts @@ -1,4 +1,5 @@ import { Commit } from "@models/Commit.model"; +import { GitProviderType } from "@models/Account.model"; import * as assert from "assert"; import { Exclude, Type } from "class-transformer"; import { Utils } from "../services/utils"; @@ -21,6 +22,7 @@ export class Repository { * @param isFork Whether this repository is a fork * @param parentUrl The parent repository URL if this is a fork * @param isChildFork Whether this repository is displayed as a child fork row + * @param provider The Git provider (github or gitlab) */ constructor( public url: string, @@ -31,12 +33,14 @@ export class Repository { public description?: string, public isFork?: boolean, public parentUrl?: string, - public isChildFork?: boolean + public isChildFork?: boolean, + public provider?: GitProviderType, ) { this.commits = commits || []; this.errors = errors || []; this.isFork = Boolean(isFork); this.isChildFork = Boolean(isChildFork); + this.provider = provider || (url?.includes("gitlab") ? "gitlab" : "github"); } /** @@ -54,11 +58,7 @@ export class Repository { * @param max_length * @returns A string with length below max_length */ - static getFormattedName( - last_name: string, - first_name: string, - max_length: number - ): string { + static getFormattedName(last_name: string, first_name: string, max_length: number): string { assert(max_length > 0); if (!last_name && !first_name) { @@ -98,35 +98,23 @@ export class Repository { if (displayName.length > Utils.OVERVIEW_NAME_LENGTH_LIMIT) { let numberOfSpace = (displayName.match(/ /g) || []).length; if (numberOfSpace === 0) { - displayName = - displayName.substring(0, Utils.OVERVIEW_NAME_LENGTH_LIMIT - 1) + "."; + displayName = displayName.substring(0, Utils.OVERVIEW_NAME_LENGTH_LIMIT - 1) + "."; } else if (numberOfSpace == 1) { let [lastName, firstName] = displayName.split(" "); - displayName = Repository.getFormattedName( - firstName, - lastName, - Utils.OVERVIEW_NAME_LENGTH_LIMIT - ); + displayName = Repository.getFormattedName(firstName, lastName, Utils.OVERVIEW_NAME_LENGTH_LIMIT); } else { let findLastName = displayName.match(/^([A-Z\-]+ )*/g); if (findLastName.length === 1 && findLastName[0].trim().length !== 0) { displayName = Repository.getFormattedName( findLastName[0].trim(), displayName.substring(findLastName[0].length), - Utils.OVERVIEW_NAME_LENGTH_LIMIT + Utils.OVERVIEW_NAME_LENGTH_LIMIT, ); } else { let lastspace = displayName.lastIndexOf(" "); - let [lastName, firstName] = [ - displayName.substring(0, lastspace), - displayName.substring(lastspace + 1), - ]; + let [lastName, firstName] = [displayName.substring(0, lastspace), displayName.substring(lastspace + 1)]; - displayName = Repository.getFormattedName( - lastName, - firstName, - Utils.OVERVIEW_NAME_LENGTH_LIMIT - ); + displayName = Repository.getFormattedName(lastName, firstName, Utils.OVERVIEW_NAME_LENGTH_LIMIT); } } } @@ -152,7 +140,9 @@ export class Repository { json.errors, json.description, json.isFork, - json.parentUrl + json.parentUrl, + json.isChildFork, + json.provider, ); } } diff --git a/src/app/services/accounts.service.spec.ts b/src/app/services/accounts.service.spec.ts index aee5bf34..4abd7659 100644 --- a/src/app/services/accounts.service.spec.ts +++ b/src/app/services/accounts.service.spec.ts @@ -213,4 +213,21 @@ describe("AccountsService", () => { service.disconnectAccount("acc-gitlab-cloud"); expect(gitlabAuthServiceSpy.signOut).toHaveBeenCalled(); }); + + it("should return correct status for hasAccount", () => { + githubAuthServiceSpy.isSignedIn.and.returnValue(true); + gitlabAuthServiceSpy.isSignedIn.and.returnValue(false); + + expect(service.hasAccount("github")).toBeTrue(); + expect(service.hasAccount("gitlab")).toBeFalse(); + }); + + it("should return data service if registered", () => { + const fakeGithubData = { provider: "github" } as any; + const fakeGitlabData = { provider: "gitlab" } as any; + const customService = new AccountsService(githubAuthServiceSpy, gitlabAuthServiceSpy, fakeGithubData, fakeGitlabData); + + expect(customService.getDataService("github")).toBe(fakeGithubData); + expect(customService.getDataService("gitlab")).toBe(fakeGitlabData); + }); }); diff --git a/src/app/services/accounts.service.ts b/src/app/services/accounts.service.ts index c54eb0ae..e9624b7f 100644 --- a/src/app/services/accounts.service.ts +++ b/src/app/services/accounts.service.ts @@ -1,9 +1,12 @@ -import { Injectable, OnDestroy } from "@angular/core"; +import { Injectable, OnDestroy, Optional } from "@angular/core"; import { BehaviorSubject, Observable, Subscription } from "rxjs"; import { GithubAuthService } from "@services/github-auth.service"; import { GitlabAuthService } from "@services/gitlab-auth.service"; +import { GithubDataService } from "@services/github-data.service"; +import { GitlabDataService } from "@services/gitlab-data.service"; import { Account, GitProviderType } from "@models/Account.model"; import { GitAuthProvider } from "@models/GitAuthProvider.model"; +import { GitDataService } from "@models/GitDataService.model"; @Injectable({ providedIn: "root", @@ -14,13 +17,26 @@ export class AccountsService implements OnDestroy { private accountsSubject = new BehaviorSubject([]); private authSubscription = new Subscription(); private providers = new Map(); + private dataServices = new Map(); - constructor(public githubAuthService: GithubAuthService, public gitlabAuthService: GitlabAuthService) { + constructor( + public githubAuthService: GithubAuthService, + public gitlabAuthService: GitlabAuthService, + @Optional() public githubDataService?: GithubDataService, + @Optional() public gitlabDataService?: GitlabDataService, + ) { this.accounts$ = this.accountsSubject.asObservable(); this.registerProvider(this.githubAuthService); this.registerProvider(this.gitlabAuthService); + if (this.githubDataService) { + this.dataServices.set("github", this.githubDataService); + } + if (this.gitlabDataService) { + this.dataServices.set("gitlab", this.gitlabDataService); + } + this.updateAccounts(); } @@ -36,6 +52,18 @@ export class AccountsService implements OnDestroy { return this.gitlabAuthService.isSignedIn(); } + hasAccount(providerType: GitProviderType): boolean { + return Boolean(this.providers.get(providerType)?.isSignedIn()); + } + + getDataService(providerType: GitProviderType = "github"): GitDataService { + const service = this.dataServices.get(providerType); + if (!service) { + return this.githubDataService; + } + return service; + } + ngOnDestroy(): void { this.authSubscription.unsubscribe(); } diff --git a/src/app/services/commits.service.ts b/src/app/services/commits.service.ts index d43f587e..e3ff8bf6 100644 --- a/src/app/services/commits.service.ts +++ b/src/app/services/commits.service.ts @@ -1,262 +1,69 @@ -import { HttpClient, HttpHeaders } from "@angular/common/http"; import { Injectable } from "@angular/core"; +import { GitProviderType } from "@models/Account.model"; import { Commit, CommitColor } from "@models/Commit.model"; -import { Error, ErrorType, Repository } from "@models/Repository.model"; +import { GitDataSearchResult } from "@models/GitDataService.model"; +import { Repository } from "@models/Repository.model"; import { TranslateService } from "@ngx-translate/core"; -import * as moment from "moment"; -import { EMPTY, forkJoin, Observable, of } from "rxjs"; -import { catchError, defaultIfEmpty, expand, map, reduce, shareReplay, switchMap, tap } from "rxjs/operators"; -import { GithubAuthService } from "./github-auth.service"; +import { Observable, of } from "rxjs"; +import { AccountsService } from "./accounts.service"; import { Utils } from "./utils"; /** - * This service retrieves repository data from Github + * Façade service retrieving repository data from Git providers (GitHub, GitLab, etc.) + * and computing graph statistics. */ @Injectable({ providedIn: "root", }) export class CommitsService { - /** - * Headers to use when sending HTTP requests - */ - get headers(): HttpHeaders { - return new HttpHeaders({ - "Content-Type": "application/json", - Authorization: "token " + this.githubAuthService.token, - }); - } - - /** - * CommitsService constructor - * @param http - * @param githubAuthService - * @param translateService - */ - constructor(private http: HttpClient, private githubAuthService: GithubAuthService, private translateService: TranslateService) {} + constructor(private accountsService: AccountsService, private translateService: TranslateService) {} /** - * Gets readMe and commits of every repository + * Gets readMe and commits of every repository by delegating to the appropriate GitDataService * @param repoTab The repositories to get data from * @param startDate The date before which commits are not retrieved * @param endDate The date after which commits are not retrieved */ getRepositories(repoTab: Repository[], startDate?: string, endDate?: string): Observable { - const t0 = performance.now(); - - const CHUNK_SIZE = 4; - const chunks: Repository[][] = []; - for (let i = 0; i < repoTab.length; i += CHUNK_SIZE) { - chunks.push(repoTab.slice(i, i + CHUNK_SIZE)); - } - - if (chunks.length === 0) { + if (!repoTab || repoTab.length === 0) { return of([]); } - - const chunkObservables = chunks.map((chunk) => this.getBatchedRepositories(chunk, startDate, endDate)); - - return forkJoin(chunkObservables).pipe( - map((results) => results.reduce((acc, val) => acc.concat(val), [])), - tap(() => { - const t1 = performance.now(); - console.log(`[Performance] getRepositories (GraphQL Batched) took ${Math.round(t1 - t0)} ms for ${repoTab.length} repos`); - }), - ); + const provider: GitProviderType = repoTab[0]?.provider || "github"; + return this.accountsService.getDataService(provider).getRepositories(repoTab, startDate, endDate); } - private getBatchedRepositories(repoTab: Repository[], startDate?: string, endDate?: string): Observable { - const repoInfos = repoTab.map((repo, index) => { - const parts = repo.url.split("/"); - return { - alias: `repo${index}`, - owner: parts[3], - name: parts[4], - repository: repo, - }; - }); - - const hasSince = !!startDate; - const hasUntil = !!endDate; - - let query = "query"; - const queryParams = []; - if (hasSince) queryParams.push("$since: GitTimestamp!"); - if (hasUntil) queryParams.push("$until: GitTimestamp!"); - if (queryParams.length > 0) { - query += "(" + queryParams.join(", ") + ")"; - } - query += " {\n"; - - repoInfos.forEach((info) => { - let historyArgs = "first: 100"; - if (hasSince) historyArgs += ", since: $since"; - if (hasUntil) historyArgs += ", until: $until"; - - query += ` - ${info.alias}: repository(owner: "${info.owner}", name: "${info.name}") { -${this.getCommitHistoryQueryFragment(historyArgs)} - identity: object(expression: "HEAD:IDENTITY.json") { - ... on Blob { text } - } - readme: object(expression: "HEAD:README.md") { - ... on Blob { text } - } - } - `; - }); - query += "}"; - - let sinceMoment = startDate ? moment(startDate).toDate().toISOString() : null; - let untilMoment = endDate ? moment(endDate).toDate().toISOString() : null; - - const variables: any = {}; - if (sinceMoment) variables.since = sinceMoment; - if (untilMoment) variables.until = untilMoment; - - return this.http - .post<{ data?: any; errors?: any[] }>("https://api.github.com/graphql", { query, variables }, { headers: this.headers }) - .pipe( - switchMap((response) => { - if (response.errors) { - console.error("GraphQL reported errors:", response.errors); - } - - const results = []; - const reposWithNextPage = []; - - repoInfos.forEach((info) => { - const repoData = response?.data?.[info.alias]; - if (!repoData) { - info.repository.errors.push(new Error(ErrorType.COMMITS_NOT_FOUND)); - results.push(info.repository); - return; - } - - const identityData = repoData.identity?.text; - const readmeData = repoData.readme?.text; - let name = ""; - let tpGroup = ""; - - if (identityData) { - try { - const identityParsed = JSON.parse(identityData); - name = this.getNameFromIdentity(identityParsed); - tpGroup = identityParsed.group; - } catch (e) {} - } else if (readmeData) { - name = this.getNameFromReadMe(readmeData); - tpGroup = this.getTPGroupFromReadMe(readmeData); - } - - const history = repoData.defaultBranchRef?.target?.history; - let commits = []; - let hasNextPage = false; - let endCursor = null; - - if (history) { - commits = history.nodes.map((node) => Commit.withGraphQLJSON(node)); - hasNextPage = history.pageInfo.hasNextPage; - endCursor = history.pageInfo.endCursor; - } - - info.repository.commits = commits; - - if (!info.repository.name) { - info.repository.name = name || info.repository.getNameFromUrl(); - } - if (!info.repository.tpGroup) { - info.repository.tpGroup = tpGroup || Utils.DEFAULT_TP_GROUP; - } - - results.push(info.repository); - - if (hasNextPage) { - reposWithNextPage.push({ - repository: info.repository, - owner: info.owner, - name: info.name, - cursor: endCursor, - }); - } - }); - - if (reposWithNextPage.length > 0) { - return this.fetchRemainingCommits(reposWithNextPage, startDate, endDate).pipe(map(() => results)); - } else { - return of(results); - } - }), - catchError((error) => { - console.error("GraphQL batch error", error); - return of(repoTab); - }), - ); + /** + * Fetch authenticated user's repositories from the Git provider + * + * @param cursor The cursor of repositories to fetch + * @param pageLimit The number of repositories to fetch per page + * @param provider The provider to query ("github" or "gitlab") + * @return An object containing the repositories, a boolean indicating if the results are complete and the next cursor + */ + getRepositoriesByAuthenticatedUser( + cursor?: string, + pageLimit = 100, + provider: GitProviderType = "github", + ): Observable { + return this.accountsService.getDataService(provider).getRepositoriesByAuthenticatedUser(cursor, pageLimit); } - private fetchRemainingCommits( - reposWithNextPage: { - repository: Repository; - owner: string; - name: string; - cursor: string; - }[], - startDate?: string, - endDate?: string, - ): Observable { - let query = "query($since: GitTimestamp, $until: GitTimestamp) {\n"; - - reposWithNextPage.forEach((info, index) => { - query += ` - repo${index}: repository(owner: "${info.owner}", name: "${info.name}") { -${this.getCommitHistoryQueryFragment(`first: 100, after: "${info.cursor}", since: $since, until: $until`)} - } - `; - }); - query += "}"; - - let sinceMoment = startDate ? moment(startDate).toDate().toISOString() : null; - let untilMoment = endDate ? moment(endDate).toDate().toISOString() : null; - - const variables: any = {}; - if (sinceMoment) variables.since = sinceMoment; - if (untilMoment) variables.until = untilMoment; - - return this.http - .post<{ data?: any; errors?: any[] }>("https://api.github.com/graphql", { query, variables }, { headers: this.headers }) - .pipe( - switchMap((response) => { - if (response.errors) { - console.error("GraphQL fetchRemainingCommits errors:", response.errors); - } - - const nextReposWithNextPage = []; - - reposWithNextPage.forEach((info, index) => { - const history = response?.data?.[`repo${index}`]?.defaultBranchRef?.target?.history; - if (history) { - const moreCommits = history.nodes.map((node) => Commit.withGraphQLJSON(node)); - info.repository.commits.push(...moreCommits); - - if (history.pageInfo.hasNextPage) { - nextReposWithNextPage.push({ - ...info, - cursor: history.pageInfo.endCursor, - }); - } - } - }); - - if (nextReposWithNextPage.length > 0) { - return this.fetchRemainingCommits(nextReposWithNextPage, startDate, endDate); - } else { - return of(null); - } - }), - catchError((error) => { - console.error("fetchRemainingCommits batch error", error); - return of(null); - }), - ); + /** + * Fetch repositories from the Git provider according to the given search filter + * + * @param searchFilter The search filter used to fetch the repositories + * @param cursor The cursor of repositories to fetch + * @param pageLimit The number of repositories to fetch per page + * @param provider The provider to query ("github" or "gitlab") + * @return An object containing the repositories, a boolean indicating if the results are complete and the next cursor + */ + getRepositoriesBySearch( + searchFilter: string, + cursor?: string, + pageLimit = 100, + provider: GitProviderType = "github", + ): Observable { + return this.accountsService.getDataService(provider).getRepositoriesBySearch(searchFilter, cursor, pageLimit); } /** @@ -482,554 +289,23 @@ ${this.getCommitHistoryQueryFragment(`first: 100, after: "${info.cursor}", since return questions.includes(q2) && this.compareQuestions(q1, q2, questions) > 0; } - /** - * Fetch authenticated user's repositories from Github - * - * @param cursor The cursor of repositories to fetch - * @param pageLimit The number of repositories to fetch per page - * @return An object containing the repositories, a boolean indicating if the results are complete and the next cursor - */ - getRepositoriesByAuthenticatedUser( - cursor?: string, - pageLimit = 100, - ): Observable<{ - completed: boolean; - repositories: Repository[]; - cursor?: string; - }> { - const query = ` - query($cursor: String, $pageLimit: Int!) { - viewer { - repositories( - first: $pageLimit - after: $cursor - affiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER] - orderBy: { field: CREATED_AT, direction: DESC } - ) { - pageInfo { - endCursor - hasNextPage - } - nodes { - name - url - description - isFork - parent { - name - url - } - forks(first: 100) { - nodes { - name - url - description - isFork - } - } - } - } - } - } - `; - const variables = { cursor, pageLimit }; - return this.http - .post<{ data: any; errors?: any[] }>("https://api.github.com/graphql", { query, variables }, { headers: this.headers }) - .pipe( - map((response) => { - const repositoriesData = response?.data?.viewer?.repositories; - if (!repositoriesData) { - return { - completed: true, - repositories: [], - cursor: undefined, - }; - } - - const reposMap = this.extractRepositoriesFromNodes(repositoriesData.nodes || []); - - return { - completed: !repositoriesData.pageInfo?.hasNextPage, - repositories: Array.from(reposMap.values()), - cursor: repositoriesData.pageInfo?.endCursor, - }; - }), - catchError((err) => { - console.error("Error fetching authenticated user repositories", err); - return of({ - completed: true, - repositories: [], - cursor: undefined, - }); - }), - ); - } - - private userOrganizations$: Observable = null; - - /** - * Fetch organizations the authenticated user belongs to (cached) - */ - getUserOrganizations(): Observable { - if (this.userOrganizations$) { - return this.userOrganizations$; - } - const query = ` - query { - viewer { - organizations(first: 100) { - nodes { - login - } - } - } - } - `; - this.userOrganizations$ = this.http - .post<{ data?: any; errors?: any[] }>("https://api.github.com/graphql", { query }, { headers: this.headers }) - .pipe( - map((response) => { - const nodes = response?.data?.viewer?.organizations?.nodes || []; - return nodes.map((org: any) => org.login).filter(Boolean); - }), - catchError((err) => { - console.error("Error fetching user organizations", err); - return of([]); - }), - shareReplay(1), - ); - return this.userOrganizations$; - } - - /** - * Helper to extract repositories and forks from GraphQL response nodes into a Map - */ - private extractRepositoriesFromNodes( - nodes: any[], - reposMap: Map = new Map(), - ): Map { - if (!nodes) return reposMap; - - nodes.forEach((node: any) => { - if (!node || !node.url) return; - - const parentUrl = node.parent?.url || undefined; - const isFork = Boolean(node.isFork || parentUrl); - - if (!reposMap.has(node.url)) { - reposMap.set(node.url, new Repository(node.url, node.name, undefined, undefined, undefined, node.description, isFork, parentUrl)); - } else { - const existing = reposMap.get(node.url); - if (!existing.parentUrl && parentUrl) { - existing.parentUrl = parentUrl; - existing.isFork = true; - } - } - - if (node.forks?.nodes) { - node.forks.nodes.forEach((fork: any) => { - if (fork && fork.url) { - if (!reposMap.has(fork.url)) { - reposMap.set( - fork.url, - new Repository(fork.url, fork.name, undefined, undefined, undefined, fork.description, true, node.url), - ); - } else { - const existingFork = reposMap.get(fork.url); - existingFork.parentUrl = node.url; - existingFork.isFork = true; - } - } - }); - } - }); - - return reposMap; - } - - /** - * Fetch repositories from Github according to the given search filter - * - * @param searchFilter The search filter used to fetch the repositories - * @param cursor The cursor of repositories to fetch - * @param pageLimit The number of repositories to fetch per page - * @return An object containing the repositories, a boolean indicating if the results are complete and the next cursor - */ - getRepositoriesBySearch( - searchFilter: string, - cursor?: string, - pageLimit = 100, - ): Observable<{ - completed: boolean; - repositories: Repository[]; - cursor?: string; - }> { - const cleanFilter = searchFilter ? searchFilter.trim() : ""; - if (!cleanFilter) { - return this.getRepositoriesByAuthenticatedUser(cursor, pageLimit); - } - - // 1. Check if the searchFilter is an organization URL (e.g. https://github.com/UE-TOAW or https://github.com/UE-TOAW/) - const orgUrlMatch = cleanFilter.match(/^(?:https?:\/\/github\.com\/|git@github\.com:)([a-zA-Z0-9_.-]+)\/?$/i); - let effectiveFilter = cleanFilter; - if (orgUrlMatch) { - effectiveFilter = orgUrlMatch[1]; - } - - // 2. Check if searchFilter is a repository URL or an "owner/name" pattern - // (e.g. "https://github.com/UE-TOAW/repo" or "UE-TOAW/tp-m2sdl-2024-friendsofmine-") - const repoMatch = effectiveFilter.match( - /^(?:https?:\/\/github\.com\/|git@github\.com:|^)([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git|\/)?$/i, - ); - - if (repoMatch) { - const owner = repoMatch[1]; - const name = repoMatch[2]; - const corePattern = Utils.extractAssignmentCore(name, owner); - const qOrg = corePattern ? `${corePattern} org:${owner} fork:true` : `org:${owner} fork:true`; - - const scopedQuery = ` - query($owner: String!, $name: String!, $qOrg: String!, $pageLimit: Int!, $cursor: String) { - repository(owner: $owner, name: $name) { - name - url - description - isFork - parent { - name - url - } - forks(first: 100) { - nodes { - name - url - description - isFork - parent { - name - url - } - } - } - } - orgSearch: search(query: $qOrg, type: REPOSITORY, first: $pageLimit, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - ... on Repository { - name - url - description - isFork - parent { - name - url - } - } - } - } - } - `; - const variables = { - owner, - name, - qOrg, - pageLimit, - cursor: cursor || null, - }; - return this.http - .post<{ data?: any; errors?: any[] }>( - "https://api.github.com/graphql", - { query: scopedQuery, variables }, - { headers: this.headers }, - ) - .pipe( - map((response) => { - const reposMap = new Map(); - - if (response?.data) { - // 1. Direct match repository is added FIRST - if (response.data.repository) { - this.extractRepositoriesFromNodes([response.data.repository], reposMap); - } - // 2. Organization search matches (e.g. all student repos in that org) - if (response.data.orgSearch?.nodes) { - this.extractRepositoriesFromNodes(response.data.orgSearch.nodes, reposMap); - } - } - - return { - completed: !response?.data?.orgSearch?.pageInfo?.hasNextPage, - repositories: Array.from(reposMap.values()), - cursor: response?.data?.orgSearch?.pageInfo?.endCursor, - }; - }), - catchError((err) => { - console.error("Error searching owner/name repositories", err); - return of({ - completed: true, - repositories: [], - cursor: undefined, - }); - }), - ); - } - - // If cursor is provided for subsequent pages, query globalSearch with after cursor - if (cursor) { - const cleanTerm = cleanFilter.replace(/[-_]+$/, ""); - const coreTerm = Utils.extractAssignmentCore(cleanTerm); - const searchKeyword = coreTerm || cleanTerm; - const query = ` - query($queryString: String!, $cursor: String, $pageLimit: Int!) { - globalSearch: search(query: $queryString, type: REPOSITORY, first: $pageLimit, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - ... on Repository { - name - url - description - isFork - parent { - name - url - } - } - } - } - } - `; - const variables = { - queryString: (searchKeyword ? `${searchKeyword} ` : "") + "fork:true", - cursor, - pageLimit, - }; - return this.http - .post<{ data: any; errors?: any[] }>("https://api.github.com/graphql", { query, variables }, { headers: this.headers }) - .pipe( - map((response) => { - const searchData = response?.data?.globalSearch; - const reposMap = this.extractRepositoriesFromNodes(searchData?.nodes || []); - return { - completed: !searchData?.pageInfo?.hasNextPage, - repositories: Array.from(reposMap.values()), - cursor: searchData?.pageInfo?.endCursor, - }; - }), - catchError((err) => { - console.error("Error searching repositories (page)", err); - return of({ - completed: true, - repositories: [], - cursor: undefined, - }); - }), - ); - } - - // 3. Free text search / Organization name: scope to authenticated user, accessible organizations and global repositories - return this.getUserOrganizations().pipe( - switchMap((orgs) => { - const isUserOrg = orgs && orgs.some((o) => o.toLowerCase() === effectiveFilter.toLowerCase()); - - const cleanTerm = effectiveFilter.replace(/[-_]+$/, ""); - const coreTerm = Utils.extractAssignmentCore(cleanTerm); - const searchKeyword = coreTerm || cleanTerm; - - const queryArgs = ["$qGlobal: String!", "$qUser: String!", "$pageLimit: Int!"]; - let queryBody = ` - globalSearch: search(query: $qGlobal, type: REPOSITORY, first: $pageLimit) { - pageInfo { - hasNextPage - endCursor - } - nodes { - ... on Repository { - name - url - description - isFork - parent { - name - url - } - } - } - } - userSearch: search(query: $qUser, type: REPOSITORY, first: $pageLimit) { - nodes { - ... on Repository { - name - url - description - isFork - parent { - name - url - } - } - } - } - `; - - const variables: any = { - qGlobal: searchKeyword ? `${searchKeyword} fork:true` : "fork:true", - qUser: searchKeyword ? `${searchKeyword} user:@me fork:true` : "user:@me fork:true", - pageLimit, - }; - - if (isUserOrg) { - queryArgs.push("$login: String!", "$qOwnerOrg: String!"); - queryBody += ` - ownerOrgSearch: search(query: $qOwnerOrg, type: REPOSITORY, first: $pageLimit) { - nodes { - ... on Repository { - name - url - description - isFork - parent { - name - url - } - } - } - } - repositoryOwner(login: $login) { - repositories(first: 100, affiliations: [OWNER, COLLABORATOR]) { - nodes { - name - url - description - isFork - parent { - name - url - } - } - } - } - `; - variables.login = effectiveFilter; - variables.qOwnerOrg = `org:${effectiveFilter} fork:true`; - } else if (orgs && orgs.length > 0) { - // Search each of the user's accessible organizations for this partial keyword - orgs.forEach((org, index) => { - const orgVar = `qOrg_${index}`; - queryArgs.push(`$${orgVar}: String!`); - queryBody += ` - orgSearch_${index}: search(query: $${orgVar}, type: REPOSITORY, first: $pageLimit) { - nodes { - ... on Repository { - name - url - description - isFork - parent { - name - url - } - } - } - } - `; - variables[orgVar] = searchKeyword ? `${searchKeyword} org:${org} fork:true` : `org:${org} fork:true`; - }); - } - - const fullQuery = `query(${queryArgs.join(", ")}) {\n${queryBody}\n}`; - - return this.http - .post<{ data: any; errors?: any[] }>("https://api.github.com/graphql", { query: fullQuery, variables }, { headers: this.headers }) - .pipe( - map((response) => { - const reposMap = new Map(); - - if (response?.data) { - Object.keys(response.data).forEach((key) => { - const field = response.data[key]; - if (key === "repositoryOwner") { - this.extractRepositoriesFromNodes(field?.repositories?.nodes || [], reposMap); - } else if (field?.nodes) { - this.extractRepositoriesFromNodes(field.nodes, reposMap); - } - }); - } - - return { - completed: !response?.data?.globalSearch?.pageInfo?.hasNextPage, - repositories: Array.from(reposMap.values()), - cursor: response?.data?.globalSearch?.pageInfo?.endCursor, - }; - }), - catchError((err) => { - console.error("Error searching repositories across user, orgs & global", err); - return of({ - completed: true, - repositories: [], - cursor: undefined, - }); - }), - ); - }), - ); - } - getNameFromReadMe(readme: string): string { - if (!readme) { - return null; - } - let lastNameToken = this.translateService.instant("TOKEN-LAST-NAME"); - let firstNameToken = this.translateService.instant("TOKEN-FIRST-NAME"); - let lastName = this.getValueWithToken(`${lastNameToken}.*:`, readme); - let firstName = this.getValueWithToken(`${firstNameToken}.*:`, readme); - return [lastName, firstName].filter(Boolean).join(" "); + return Utils.getNameFromReadMe( + readme, + this.translateService.instant("TOKEN-LAST-NAME"), + this.translateService.instant("TOKEN-FIRST-NAME"), + ); } getNameFromIdentity(identity: any): string { - return [identity.last_name, identity.first_name].filter(Boolean).join(" "); + return Utils.getNameFromIdentity(identity); } getTPGroupFromReadMe(readme: string): string { - if (!readme) { - return null; - } - let tpGroup = this.getValueWithToken("-\\s*\\[\\S\\]", readme); - return tpGroup; + return Utils.getTPGroupFromReadMe(readme); } getValueWithToken(token: string, text: string): string { - let regex = new RegExp(`(?<=${token}).*`); - let value = text.match(regex); - return value ? value[0].trim() : null; - } - - private getCommitHistoryQueryFragment(historyArgs: string): string { - return ` - defaultBranchRef { - target { - ... on Commit { - history(${historyArgs}) { - pageInfo { - hasNextPage - endCursor - } - nodes { - message - author { - name - } - committedDate - url - } - } - } - } - }`; + return Utils.getValueWithToken(token, text); } } diff --git a/src/app/services/database.service.ts b/src/app/services/database.service.ts index d3f84d8b..c9806a19 100644 --- a/src/app/services/database.service.ts +++ b/src/app/services/database.service.ts @@ -58,6 +58,27 @@ export class DatabaseService extends Dexie { } }); }); + this.version(4) + .stores({ + assignments: "++id, metadata.title, provider", + }) + .upgrade((tx) => { + return tx + .table("assignments") + .toCollection() + .modify((assignment) => { + if (!assignment.provider) { + assignment.provider = "github"; + } + if (assignment.repositories && Array.isArray(assignment.repositories)) { + assignment.repositories.forEach((repo: any) => { + if (!repo.provider) { + repo.provider = "github"; + } + }); + } + }); + }); this.assignments = this.table("assignments"); this.assignments.mapToClass(Assignment); } diff --git a/src/app/services/github-data.service.spec.ts b/src/app/services/github-data.service.spec.ts new file mode 100644 index 00000000..f4e6ba94 --- /dev/null +++ b/src/app/services/github-data.service.spec.ts @@ -0,0 +1,80 @@ +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { TestBed } from "@angular/core/testing"; +import { TranslateService } from "@ngx-translate/core"; +import { GithubAuthService } from "./github-auth.service"; +import { GithubDataService } from "./github-data.service"; + +describe("GithubDataService", () => { + let service: GithubDataService; + let httpMock: HttpTestingController; + let githubAuthServiceSpy: jasmine.SpyObj; + let translateServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + githubAuthServiceSpy = { + token: "test-token-github", + isSignedIn: jasmine.createSpy("isSignedIn").and.returnValue(true), + } as any; + + translateServiceSpy = { + instant: jasmine.createSpy("instant").and.callFake((key: string) => key), + } as any; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + GithubDataService, + { provide: GithubAuthService, useValue: githubAuthServiceSpy }, + { provide: TranslateService, useValue: translateServiceSpy }, + ], + }); + + service = TestBed.inject(GithubDataService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it("should be created with provider github", () => { + expect(service).toBeTruthy(); + expect(service.provider).toBe("github"); + }); + + it("should verify user access by querying repository endpoint", () => { + service.verifyUserAccess("https://github.com/octocat/Hello-World").subscribe((res) => { + expect(res).toBeTruthy(); + expect(res.name).toBe("Hello-World"); + }); + + const req = httpMock.expectOne("https://api.github.com/repos/octocat/Hello-World"); + expect(req.request.method).toBe("GET"); + expect(req.request.headers.get("Authorization")).toBe("token test-token-github"); + req.flush({ name: "Hello-World" }); + }); + + it("should fetch user organizations with caching", () => { + service.getUserOrganizations().subscribe((orgs) => { + expect(orgs).toEqual(["my-org"]); + }); + + const req = httpMock.expectOne("https://api.github.com/graphql"); + expect(req.request.method).toBe("POST"); + req.flush({ + data: { + viewer: { + organizations: { + nodes: [{ login: "my-org" }], + }, + }, + }, + }); + + // Second call should return cached without HTTP call + service.getUserOrganizations().subscribe((orgs) => { + expect(orgs).toEqual(["my-org"]); + }); + httpMock.expectNone("https://api.github.com/graphql"); + }); +}); diff --git a/src/app/services/github-data.service.ts b/src/app/services/github-data.service.ts new file mode 100644 index 00000000..4fcda097 --- /dev/null +++ b/src/app/services/github-data.service.ts @@ -0,0 +1,735 @@ +import { HttpClient, HttpHeaders } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { GitProviderType } from "@models/Account.model"; +import { Commit } from "@models/Commit.model"; +import { GitDataSearchResult, GitDataService } from "@models/GitDataService.model"; +import { Error, ErrorType, Repository } from "@models/Repository.model"; +import { TranslateService } from "@ngx-translate/core"; +import * as moment from "moment"; +import { forkJoin, Observable, of } from "rxjs"; +import { catchError, map, shareReplay, switchMap, tap } from "rxjs/operators"; +import { GithubAuthService } from "./github-auth.service"; +import { Utils } from "./utils"; + +@Injectable({ + providedIn: "root", +}) +export class GithubDataService implements GitDataService { + public readonly provider: GitProviderType = "github"; + + private userOrganizations$: Observable = null; + + constructor(private http: HttpClient, private githubAuthService: GithubAuthService, private translateService: TranslateService) {} + + get headers(): HttpHeaders { + return new HttpHeaders({ + "Content-Type": "application/json", + Authorization: "token " + this.githubAuthService.token, + }); + } + + getRepositories(repoTab: Repository[], startDate?: string, endDate?: string): Observable { + const t0 = performance.now(); + const CHUNK_SIZE = 4; + const chunks: Repository[][] = []; + for (let i = 0; i < repoTab.length; i += CHUNK_SIZE) { + chunks.push(repoTab.slice(i, i + CHUNK_SIZE)); + } + + if (chunks.length === 0) { + return of([]); + } + + const chunkObservables = chunks.map((chunk) => this.getBatchedRepositories(chunk, startDate, endDate)); + + return forkJoin(chunkObservables).pipe( + map((results) => results.reduce((acc, val) => acc.concat(val), [])), + tap(() => { + const t1 = performance.now(); + console.log(`[Performance] GithubDataService getRepositories took ${Math.round(t1 - t0)} ms for ${repoTab.length} repos`); + }), + ); + } + + getRepositoriesByAuthenticatedUser(cursor?: string, pageLimit = 100): Observable { + const query = ` + query($cursor: String, $pageLimit: Int!) { + viewer { + repositories( + first: $pageLimit + after: $cursor + affiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER] + orderBy: { field: CREATED_AT, direction: DESC } + ) { + pageInfo { + endCursor + hasNextPage + } + nodes { + name + url + description + isFork + parent { + name + url + } + forks(first: 100) { + nodes { + name + url + description + isFork + } + } + } + } + } + } + `; + const variables = { cursor, pageLimit }; + return this.http + .post<{ data: any; errors?: any[] }>("https://api.github.com/graphql", { query, variables }, { headers: this.headers }) + .pipe( + map((response) => { + const repositoriesData = response?.data?.viewer?.repositories; + if (!repositoriesData) { + return { + completed: true, + repositories: [], + cursor: undefined, + }; + } + + const reposMap = this.extractRepositoriesFromNodes(repositoriesData.nodes || []); + + return { + completed: !repositoriesData.pageInfo?.hasNextPage, + repositories: Array.from(reposMap.values()), + cursor: repositoriesData.pageInfo?.endCursor, + }; + }), + catchError((err) => { + console.error("Error fetching authenticated GitHub user repositories", err); + return of({ + completed: true, + repositories: [], + cursor: undefined, + }); + }), + ); + } + + getRepositoriesBySearch(searchFilter: string, cursor?: string, pageLimit = 100): Observable { + const cleanFilter = searchFilter ? searchFilter.trim() : ""; + if (!cleanFilter) { + return this.getRepositoriesByAuthenticatedUser(cursor, pageLimit); + } + + const orgUrlMatch = cleanFilter.match(/^(?:https?:\/\/github\.com\/|git@github\.com:)([a-zA-Z0-9_.-]+)\/?$/i); + let effectiveFilter = cleanFilter; + if (orgUrlMatch) { + effectiveFilter = orgUrlMatch[1]; + } + + const repoMatch = effectiveFilter.match( + /^(?:https?:\/\/github\.com\/|git@github\.com:|^)([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git|\/)?$/i, + ); + + if (repoMatch) { + const owner = repoMatch[1]; + const name = repoMatch[2]; + const corePattern = Utils.extractAssignmentCore(name, owner); + const qOrg = corePattern ? `${corePattern} org:${owner} fork:true` : `org:${owner} fork:true`; + + const scopedQuery = ` + query($owner: String!, $name: String!, $qOrg: String!, $pageLimit: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + name + url + description + isFork + parent { + name + url + } + forks(first: 100) { + nodes { + name + url + description + isFork + parent { + name + url + } + } + } + } + orgSearch: search(query: $qOrg, type: REPOSITORY, first: $pageLimit, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + ... on Repository { + name + url + description + isFork + parent { + name + url + } + } + } + } + } + `; + const variables = { + owner, + name, + qOrg, + pageLimit, + cursor: cursor || null, + }; + return this.http + .post<{ data?: any; errors?: any[] }>( + "https://api.github.com/graphql", + { query: scopedQuery, variables }, + { headers: this.headers }, + ) + .pipe( + map((response) => { + const reposMap = new Map(); + + if (response?.data) { + if (response.data.repository) { + this.extractRepositoriesFromNodes([response.data.repository], reposMap); + } + if (response.data.orgSearch?.nodes) { + this.extractRepositoriesFromNodes(response.data.orgSearch.nodes, reposMap); + } + } + + return { + completed: !response?.data?.orgSearch?.pageInfo?.hasNextPage, + repositories: Array.from(reposMap.values()), + cursor: response?.data?.orgSearch?.pageInfo?.endCursor, + }; + }), + catchError((err) => { + console.error("Error searching GitHub owner/name repositories", err); + return of({ + completed: true, + repositories: [], + cursor: undefined, + }); + }), + ); + } + + if (cursor) { + const cleanTerm = cleanFilter.replace(/[-_]+$/, ""); + const coreTerm = Utils.extractAssignmentCore(cleanTerm); + const searchKeyword = coreTerm || cleanTerm; + const query = ` + query($queryString: String!, $cursor: String, $pageLimit: Int!) { + globalSearch: search(query: $queryString, type: REPOSITORY, first: $pageLimit, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + ... on Repository { + name + url + description + isFork + parent { + name + url + } + } + } + } + } + `; + const variables = { + queryString: (searchKeyword ? `${searchKeyword} ` : "") + "fork:true", + cursor, + pageLimit, + }; + return this.http + .post<{ data: any; errors?: any[] }>("https://api.github.com/graphql", { query, variables }, { headers: this.headers }) + .pipe( + map((response) => { + const searchData = response?.data?.globalSearch; + const reposMap = this.extractRepositoriesFromNodes(searchData?.nodes || []); + return { + completed: !searchData?.pageInfo?.hasNextPage, + repositories: Array.from(reposMap.values()), + cursor: searchData?.pageInfo?.endCursor, + }; + }), + catchError((err) => { + console.error("Error searching GitHub repositories (page)", err); + return of({ + completed: true, + repositories: [], + cursor: undefined, + }); + }), + ); + } + + return this.getUserOrganizations().pipe( + switchMap((orgs) => { + const isUserOrg = orgs && orgs.some((o) => o.toLowerCase() === effectiveFilter.toLowerCase()); + const cleanTerm = effectiveFilter.replace(/[-_]+$/, ""); + const coreTerm = Utils.extractAssignmentCore(cleanTerm); + const searchKeyword = coreTerm || cleanTerm; + + const queryArgs = ["$qGlobal: String!", "$qUser: String!", "$pageLimit: Int!"]; + let queryBody = ` + globalSearch: search(query: $qGlobal, type: REPOSITORY, first: $pageLimit) { + pageInfo { + hasNextPage + endCursor + } + nodes { + ... on Repository { + name + url + description + isFork + parent { + name + url + } + } + } + } + userSearch: search(query: $qUser, type: REPOSITORY, first: $pageLimit) { + nodes { + ... on Repository { + name + url + description + isFork + parent { + name + url + } + } + } + } + `; + + const variables: any = { + qGlobal: searchKeyword ? `${searchKeyword} fork:true` : "fork:true", + qUser: searchKeyword ? `${searchKeyword} user:@me fork:true` : "user:@me fork:true", + pageLimit, + }; + + if (isUserOrg) { + queryArgs.push("$login: String!", "$qOwnerOrg: String!"); + queryBody += ` + ownerOrgSearch: search(query: $qOwnerOrg, type: REPOSITORY, first: $pageLimit) { + nodes { + ... on Repository { + name + url + description + isFork + parent { + name + url + } + } + } + } + repositoryOwner(login: $login) { + repositories(first: 100, affiliations: [OWNER, COLLABORATOR]) { + nodes { + name + url + description + isFork + parent { + name + url + } + } + } + } + `; + variables.login = effectiveFilter; + variables.qOwnerOrg = `org:${effectiveFilter} fork:true`; + } else if (orgs && orgs.length > 0) { + orgs.forEach((org, index) => { + const orgVar = `qOrg_${index}`; + queryArgs.push(`$${orgVar}: String!`); + queryBody += ` + orgSearch_${index}: search(query: $${orgVar}, type: REPOSITORY, first: $pageLimit) { + nodes { + ... on Repository { + name + url + description + isFork + parent { + name + url + } + } + } + } + `; + variables[orgVar] = searchKeyword ? `${searchKeyword} org:${org} fork:true` : `org:${org} fork:true`; + }); + } + + const fullQuery = `query(${queryArgs.join(", ")}) {\n${queryBody}\n}`; + + return this.http + .post<{ data: any; errors?: any[] }>("https://api.github.com/graphql", { query: fullQuery, variables }, { headers: this.headers }) + .pipe( + map((response) => { + const reposMap = new Map(); + + if (response?.data) { + Object.keys(response.data).forEach((key) => { + const field = response.data[key]; + if (key === "repositoryOwner") { + this.extractRepositoriesFromNodes(field?.repositories?.nodes || [], reposMap); + } else if (field?.nodes) { + this.extractRepositoriesFromNodes(field.nodes, reposMap); + } + }); + } + + return { + completed: !response?.data?.globalSearch?.pageInfo?.hasNextPage, + repositories: Array.from(reposMap.values()), + cursor: response?.data?.globalSearch?.pageInfo?.endCursor, + }; + }), + catchError((err) => { + console.error("Error searching GitHub repositories across user, orgs & global", err); + return of({ + completed: true, + repositories: [], + cursor: undefined, + }); + }), + ); + }), + ); + } + + verifyUserAccess(repoUrl: string): Observable { + const parts = repoUrl.split("/"); + const url = "https://api.github.com/repos/" + parts[3] + "/" + parts[4]; + return this.http.get(url, { headers: this.headers }); + } + + getUserOrganizations(): Observable { + if (this.userOrganizations$) { + return this.userOrganizations$; + } + const query = ` + query { + viewer { + organizations(first: 100) { + nodes { + login + } + } + } + } + `; + this.userOrganizations$ = this.http + .post<{ data?: any; errors?: any[] }>("https://api.github.com/graphql", { query }, { headers: this.headers }) + .pipe( + map((response) => { + const nodes = response?.data?.viewer?.organizations?.nodes || []; + return nodes.map((org: any) => org.login).filter(Boolean); + }), + catchError((err) => { + console.error("Error fetching GitHub user organizations", err); + return of([]); + }), + shareReplay(1), + ); + return this.userOrganizations$; + } + + private getBatchedRepositories(repoTab: Repository[], startDate?: string, endDate?: string): Observable { + const repoInfos = repoTab.map((repo, index) => { + const parts = repo.url.split("/"); + return { + alias: `repo${index}`, + owner: parts[3], + name: parts[4], + repository: repo, + }; + }); + + const hasSince = !!startDate; + const hasUntil = !!endDate; + + let query = "query"; + const queryParams = []; + if (hasSince) queryParams.push("$since: GitTimestamp!"); + if (hasUntil) queryParams.push("$until: GitTimestamp!"); + if (queryParams.length > 0) { + query += "(" + queryParams.join(", ") + ")"; + } + query += " {\n"; + + repoInfos.forEach((info) => { + let historyArgs = "first: 100"; + if (hasSince) historyArgs += ", since: $since"; + if (hasUntil) historyArgs += ", until: $until"; + + query += ` + ${info.alias}: repository(owner: "${info.owner}", name: "${info.name}") { +${this.getCommitHistoryQueryFragment(historyArgs)} + identity: object(expression: "HEAD:IDENTITY.json") { + ... on Blob { text } + } + readme: object(expression: "HEAD:README.md") { + ... on Blob { text } + } + } + `; + }); + query += "}"; + + let sinceMoment = startDate ? moment(startDate).toDate().toISOString() : null; + let untilMoment = endDate ? moment(endDate).toDate().toISOString() : null; + + const variables: any = {}; + if (sinceMoment) variables.since = sinceMoment; + if (untilMoment) variables.until = untilMoment; + + return this.http + .post<{ data?: any; errors?: any[] }>("https://api.github.com/graphql", { query, variables }, { headers: this.headers }) + .pipe( + switchMap((response) => { + if (response.errors) { + console.error("GraphQL reported errors:", response.errors); + } + + const results: Repository[] = []; + const reposWithNextPage = []; + + repoInfos.forEach((info) => { + const repoData = response?.data?.[info.alias]; + if (!repoData) { + info.repository.errors.push(new Error(ErrorType.COMMITS_NOT_FOUND)); + results.push(info.repository); + return; + } + + const identityData = repoData.identity?.text; + const readmeData = repoData.readme?.text; + let name = ""; + let tpGroup = ""; + + if (identityData) { + try { + const identityParsed = JSON.parse(identityData); + name = Utils.getNameFromIdentity(identityParsed); + tpGroup = identityParsed.group; + } catch (e) {} + } else if (readmeData) { + name = Utils.getNameFromReadMe( + readmeData, + this.translateService.instant("TOKEN-LAST-NAME"), + this.translateService.instant("TOKEN-FIRST-NAME"), + ); + tpGroup = Utils.getTPGroupFromReadMe(readmeData); + } + + const history = repoData.defaultBranchRef?.target?.history; + let commits = []; + let hasNextPage = false; + let endCursor = null; + + if (history) { + commits = history.nodes.map((node: any) => Commit.withGraphQLJSON(node)); + hasNextPage = history.pageInfo.hasNextPage; + endCursor = history.pageInfo.endCursor; + } + + info.repository.commits = commits; + info.repository.provider = "github"; + + if (!info.repository.name) { + info.repository.name = name || info.repository.getNameFromUrl(); + } + if (!info.repository.tpGroup) { + info.repository.tpGroup = tpGroup || Utils.DEFAULT_TP_GROUP; + } + + results.push(info.repository); + + if (hasNextPage) { + reposWithNextPage.push({ + repository: info.repository, + owner: info.owner, + name: info.name, + cursor: endCursor, + }); + } + }); + + if (reposWithNextPage.length > 0) { + return this.fetchRemainingCommits(reposWithNextPage, startDate, endDate).pipe(map(() => results)); + } else { + return of(results); + } + }), + catchError((error) => { + console.error("GraphQL batch error", error); + return of(repoTab); + }), + ); + } + + private fetchRemainingCommits( + reposWithNextPage: { + repository: Repository; + owner: string; + name: string; + cursor: string; + }[], + startDate?: string, + endDate?: string, + ): Observable { + let query = "query($since: GitTimestamp, $until: GitTimestamp) {\n"; + + reposWithNextPage.forEach((info, index) => { + query += ` + repo${index}: repository(owner: "${info.owner}", name: "${info.name}") { +${this.getCommitHistoryQueryFragment(`first: 100, after: "${info.cursor}", since: $since, until: $until`)} + } + `; + }); + query += "}"; + + let sinceMoment = startDate ? moment(startDate).toDate().toISOString() : null; + let untilMoment = endDate ? moment(endDate).toDate().toISOString() : null; + + const variables: any = {}; + if (sinceMoment) variables.since = sinceMoment; + if (untilMoment) variables.until = untilMoment; + + return this.http + .post<{ data?: any; errors?: any[] }>("https://api.github.com/graphql", { query, variables }, { headers: this.headers }) + .pipe( + switchMap((response) => { + if (response.errors) { + console.error("GraphQL fetchRemainingCommits errors:", response.errors); + } + + const nextReposWithNextPage = []; + + reposWithNextPage.forEach((info, index) => { + const history = response?.data?.[`repo${index}`]?.defaultBranchRef?.target?.history; + if (history) { + const moreCommits = history.nodes.map((node: any) => Commit.withGraphQLJSON(node)); + info.repository.commits.push(...moreCommits); + + if (history.pageInfo.hasNextPage) { + nextReposWithNextPage.push({ + ...info, + cursor: history.pageInfo.endCursor, + }); + } + } + }); + + if (nextReposWithNextPage.length > 0) { + return this.fetchRemainingCommits(nextReposWithNextPage, startDate, endDate); + } else { + return of(null); + } + }), + catchError((error) => { + console.error("fetchRemainingCommits batch error", error); + return of(null); + }), + ); + } + + private extractRepositoriesFromNodes( + nodes: any[], + reposMap: Map = new Map(), + ): Map { + if (!nodes) return reposMap; + + nodes.forEach((node: any) => { + if (!node || !node.url) return; + + const parentUrl = node.parent?.url || undefined; + const isFork = Boolean(node.isFork || parentUrl); + + if (!reposMap.has(node.url)) { + const repo = new Repository(node.url, node.name, undefined, undefined, undefined, node.description, isFork, parentUrl); + repo.provider = "github"; + reposMap.set(node.url, repo); + } else { + const existing = reposMap.get(node.url); + if (!existing.parentUrl && parentUrl) { + existing.parentUrl = parentUrl; + existing.isFork = true; + } + } + + if (node.forks?.nodes) { + node.forks.nodes.forEach((fork: any) => { + if (fork && fork.url) { + if (!reposMap.has(fork.url)) { + const forkRepo = new Repository(fork.url, fork.name, undefined, undefined, undefined, fork.description, true, node.url); + forkRepo.provider = "github"; + reposMap.set(fork.url, forkRepo); + } else { + const existingFork = reposMap.get(fork.url); + existingFork.parentUrl = node.url; + existingFork.isFork = true; + } + } + }); + } + }); + + return reposMap; + } + + private getCommitHistoryQueryFragment(historyArgs: string): string { + return ` + defaultBranchRef { + target { + ... on Commit { + history(${historyArgs}) { + pageInfo { + hasNextPage + endCursor + } + nodes { + message + author { + name + } + committedDate + url + } + } + } + } + }`; + } +} diff --git a/src/app/services/gitlab-data.service.spec.ts b/src/app/services/gitlab-data.service.spec.ts new file mode 100644 index 00000000..4bc8b677 --- /dev/null +++ b/src/app/services/gitlab-data.service.spec.ts @@ -0,0 +1,98 @@ +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { TestBed } from "@angular/core/testing"; +import { TranslateService } from "@ngx-translate/core"; +import { GitlabAuthService } from "./gitlab-auth.service"; +import { GitlabDataService } from "./gitlab-data.service"; + +describe("GitlabDataService", () => { + let service: GitlabDataService; + let httpMock: HttpTestingController; + let gitlabAuthServiceSpy: jasmine.SpyObj; + let translateServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + gitlabAuthServiceSpy = { + token: "test-token-gitlab", + instanceHost: "gitlab.com", + isSignedIn: jasmine.createSpy("isSignedIn").and.returnValue(true), + } as any; + + translateServiceSpy = { + instant: jasmine.createSpy("instant").and.callFake((key: string) => key), + } as any; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + GitlabDataService, + { provide: GitlabAuthService, useValue: gitlabAuthServiceSpy }, + { provide: TranslateService, useValue: translateServiceSpy }, + ], + }); + + service = TestBed.inject(GitlabDataService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it("should be created with provider gitlab", () => { + expect(service).toBeTruthy(); + expect(service.provider).toBe("gitlab"); + }); + + it("should verify user access by querying gitlab project endpoint", () => { + service.verifyUserAccess("https://gitlab.com/univ/course/student-repo").subscribe((res) => { + expect(res).toBeTruthy(); + expect(res.name).toBe("student-repo"); + expect(res.owner.avatar_url).toBe("https://example.com/avatar.png"); + }); + + const encodedPath = encodeURIComponent("univ/course/student-repo"); + const req = httpMock.expectOne(`https://gitlab.com/api/v4/projects/${encodedPath}`); + expect(req.request.method).toBe("GET"); + expect(req.request.headers.get("Authorization")).toBe("Bearer test-token-gitlab"); + req.flush({ + name: "student-repo", + avatar_url: "https://example.com/avatar.png", + namespace: { avatar_url: null }, + }); + }); + + it("should support self-hosted GitLab instance URLs", () => { + service.verifyUserAccess("https://gitlab.univ-nantes.fr/group/subgroup/project.git").subscribe((res) => { + expect(res).toBeTruthy(); + expect(res.name).toBe("project"); + }); + + const encodedPath = encodeURIComponent("group/subgroup/project"); + const req = httpMock.expectOne(`https://gitlab.univ-nantes.fr/api/v4/projects/${encodedPath}`); + expect(req.request.method).toBe("GET"); + req.flush({ name: "project" }); + }); + + it("should fetch authenticated user repositories with pagination", () => { + service.getRepositoriesByAuthenticatedUser().subscribe((result) => { + expect(result.completed).toBeTrue(); + expect(result.repositories.length).toBe(1); + expect(result.repositories[0].name).toBe("group/my-repo"); + expect(result.repositories[0].provider).toBe("gitlab"); + }); + + const req = httpMock.expectOne("https://gitlab.com/api/v4/projects?membership=true&order_by=created_at&sort=desc&per_page=100&page=1"); + expect(req.request.method).toBe("GET"); + req.flush( + [ + { + web_url: "https://gitlab.com/group/my-repo", + path_with_namespace: "group/my-repo", + name: "my-repo", + description: "Test repo", + }, + ], + { headers: { "x-next-page": "" } }, + ); + }); +}); diff --git a/src/app/services/gitlab-data.service.ts b/src/app/services/gitlab-data.service.ts new file mode 100644 index 00000000..afb88f26 --- /dev/null +++ b/src/app/services/gitlab-data.service.ts @@ -0,0 +1,341 @@ +import { HttpClient, HttpHeaders, HttpResponse } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { GitProviderType } from "@models/Account.model"; +import { Commit } from "@models/Commit.model"; +import { GitDataSearchResult, GitDataService } from "@models/GitDataService.model"; +import { Error, ErrorType, Repository } from "@models/Repository.model"; +import { TranslateService } from "@ngx-translate/core"; +import * as moment from "moment"; +import { forkJoin, Observable, of } from "rxjs"; +import { catchError, map, switchMap, tap } from "rxjs/operators"; +import { GitlabAuthService } from "./gitlab-auth.service"; +import { Utils } from "./utils"; + +@Injectable({ + providedIn: "root", +}) +export class GitlabDataService implements GitDataService { + public readonly provider: GitProviderType = "gitlab"; + + constructor(private http: HttpClient, private gitlabAuthService: GitlabAuthService, private translateService: TranslateService) {} + + get headers(): HttpHeaders { + return new HttpHeaders({ + "Content-Type": "application/json", + Authorization: `Bearer ${this.gitlabAuthService.token}`, + }); + } + + getRepositories(repoTab: Repository[], startDate?: string, endDate?: string): Observable { + const t0 = performance.now(); + if (!repoTab || repoTab.length === 0) { + return of([]); + } + + const observables = repoTab.map((repo) => this.fetchSingleRepositoryData(repo, startDate, endDate)); + + return forkJoin(observables).pipe( + tap(() => { + const t1 = performance.now(); + console.log(`[Performance] GitlabDataService getRepositories took ${Math.round(t1 - t0)} ms for ${repoTab.length} repos`); + }), + ); + } + + getRepositoriesByAuthenticatedUser(cursor?: string, pageLimit = 100): Observable { + const baseApi = this.getBaseApi(); + const page = cursor ? parseInt(cursor, 10) : 1; + const url = `${baseApi}/projects?membership=true&order_by=created_at&sort=desc&per_page=${pageLimit}&page=${page}`; + + return this.http.get(url, { headers: this.headers, observe: "response" }).pipe( + map((response: HttpResponse) => { + const nextPage = response.headers.get("x-next-page"); + const projects = response.body || []; + const repositories = projects.map((p) => this.mapProjectToRepository(p)); + + return { + completed: !nextPage || nextPage.trim() === "", + repositories, + cursor: nextPage && nextPage.trim() !== "" ? nextPage.trim() : undefined, + }; + }), + catchError((err) => { + console.error("Error fetching authenticated GitLab user repositories", err); + return of({ + completed: true, + repositories: [], + cursor: undefined, + }); + }), + ); + } + + getRepositoriesBySearch(searchFilter: string, cursor?: string, pageLimit = 100): Observable { + const cleanFilter = searchFilter ? searchFilter.trim() : ""; + if (!cleanFilter) { + return this.getRepositoriesByAuthenticatedUser(cursor, pageLimit); + } + + const baseApi = this.getBaseApi(); + const page = cursor ? parseInt(cursor, 10) : 1; + + // Check if filter is a full URL or owner/name + const repoInfo = this.extractRepoPathAndOrigin(cleanFilter); + if (repoInfo.path) { + const projectApiUrl = `${repoInfo.origin}/api/v4/projects/${encodeURIComponent(repoInfo.path)}`; + const forksUrl = `${repoInfo.origin}/api/v4/projects/${encodeURIComponent(repoInfo.path)}/forks?per_page=${pageLimit}&page=${page}`; + + return forkJoin({ + project: this.http.get(projectApiUrl, { headers: this.headers }).pipe(catchError(() => of(null))), + forks: this.http.get(forksUrl, { headers: this.headers, observe: "response" }).pipe(catchError(() => of(null))), + }).pipe( + map(({ project, forks }) => { + const reposMap = new Map(); + if (project && project.web_url) { + const r = this.mapProjectToRepository(project); + reposMap.set(r.url.toLowerCase(), r); + } + let nextPage: string | null = null; + if (forks) { + nextPage = forks.headers?.get("x-next-page"); + (forks.body || []).forEach((f: any) => { + const r = this.mapProjectToRepository(f, project?.web_url); + reposMap.set(r.url.toLowerCase(), r); + }); + } + + if (reposMap.size > 0) { + return { + completed: !nextPage || nextPage.trim() === "", + repositories: Array.from(reposMap.values()), + cursor: nextPage && nextPage.trim() !== "" ? nextPage.trim() : undefined, + }; + } + return null; + }), + switchMap((result) => { + if (result) { + return of(result); + } + return this.searchProjectsByKeyword(cleanFilter, page, pageLimit, baseApi); + }), + ); + } + + return this.searchProjectsByKeyword(cleanFilter, page, pageLimit, baseApi); + } + + verifyUserAccess(repoUrl: string): Observable { + const { origin, path } = this.extractRepoPathAndOrigin(repoUrl); + if (!path) { + return of({ valid: false }); + } + const url = `${origin}/api/v4/projects/${encodeURIComponent(path)}`; + return this.http.get(url, { headers: this.headers }).pipe( + map((project) => ({ + ...project, + owner: { + avatar_url: project.avatar_url || project.namespace?.avatar_url || null, + }, + avatar_url: project.avatar_url || project.namespace?.avatar_url || null, + })), + ); + } + + private searchProjectsByKeyword(keyword: string, page: number, pageLimit: number, baseApi: string): Observable { + const memberUrl = `${baseApi}/projects?search=${encodeURIComponent(keyword)}&membership=true&per_page=${pageLimit}&page=${page}`; + const allUrl = `${baseApi}/projects?search=${encodeURIComponent(keyword)}&per_page=${pageLimit}&page=${page}`; + + return forkJoin({ + memberRes: this.http.get(memberUrl, { headers: this.headers, observe: "response" }).pipe(catchError(() => of(null))), + allRes: this.http.get(allUrl, { headers: this.headers, observe: "response" }).pipe(catchError(() => of(null))), + }).pipe( + map(({ memberRes, allRes }) => { + const reposMap = new Map(); + const nextPage = memberRes?.headers?.get("x-next-page") || allRes?.headers?.get("x-next-page"); + + const memberProjects = memberRes?.body || []; + const allProjects = allRes?.body || []; + + [...memberProjects, ...allProjects].forEach((p) => { + if (p && p.web_url) { + const r = this.mapProjectToRepository(p); + reposMap.set(r.url.toLowerCase(), r); + } + }); + + return { + completed: !nextPage || nextPage.trim() === "", + repositories: Array.from(reposMap.values()), + cursor: nextPage && nextPage.trim() !== "" ? nextPage.trim() : undefined, + }; + }), + catchError((err) => { + console.error("Error searching GitLab projects", err); + return of({ + completed: true, + repositories: [], + cursor: undefined, + }); + }), + ); + } + + private fetchSingleRepositoryData(repo: Repository, startDate?: string, endDate?: string): Observable { + const { origin, path } = this.extractRepoPathAndOrigin(repo.url); + if (!path) { + repo.errors.push(new Error(ErrorType.COMMITS_NOT_FOUND)); + return of(repo); + } + + const projectUrl = `${origin}/api/v4/projects/${encodeURIComponent(path)}`; + + return this.http.get(projectUrl, { headers: this.headers }).pipe( + switchMap((project) => { + const defaultBranch = project?.default_branch || "main"; + const readmeUrl = `${origin}/api/v4/projects/${encodeURIComponent(path)}/repository/files/README%2Emd/raw?ref=${encodeURIComponent( + defaultBranch, + )}`; + const identityUrl = `${origin}/api/v4/projects/${encodeURIComponent( + path, + )}/repository/files/IDENTITY%2Ejson/raw?ref=${encodeURIComponent(defaultBranch)}`; + + return forkJoin({ + readme: this.http.get(readmeUrl, { headers: this.headers, responseType: "text" }).pipe(catchError(() => of(null))), + identity: this.http.get(identityUrl, { headers: this.headers, responseType: "text" }).pipe(catchError(() => of(null))), + commits: this.fetchAllCommits(origin, path, defaultBranch, startDate, endDate), + }).pipe( + map(({ readme, identity, commits }) => { + let name = ""; + let tpGroup = ""; + + if (identity) { + try { + const parsed = JSON.parse(identity); + name = Utils.getNameFromIdentity(parsed); + tpGroup = parsed.group; + } catch (e) {} + } else if (readme) { + name = Utils.getNameFromReadMe( + readme, + this.translateService.instant("TOKEN-LAST-NAME"), + this.translateService.instant("TOKEN-FIRST-NAME"), + ); + tpGroup = Utils.getTPGroupFromReadMe(readme); + } + + repo.commits = commits; + repo.provider = "gitlab"; + + if (!repo.name) { + repo.name = name || repo.getNameFromUrl(); + } + if (!repo.tpGroup) { + repo.tpGroup = tpGroup || Utils.DEFAULT_TP_GROUP; + } + + return repo; + }), + ); + }), + catchError((err) => { + console.error(`Error fetching GitLab repo data for ${repo.url}`, err); + repo.errors.push(new Error(ErrorType.COMMITS_NOT_FOUND)); + return of(repo); + }), + ); + } + + private fetchAllCommits(origin: string, path: string, defaultBranch: string, startDate?: string, endDate?: string): Observable { + let url = `${origin}/api/v4/projects/${encodeURIComponent(path)}/repository/commits?ref_name=${encodeURIComponent( + defaultBranch, + )}&per_page=100`; + if (startDate) { + url += `&since=${encodeURIComponent(moment(startDate).toDate().toISOString())}`; + } + if (endDate) { + url += `&until=${encodeURIComponent(moment(endDate).toDate().toISOString())}`; + } + + return this.fetchCommitsPage(url, 1, []); + } + + private fetchCommitsPage(baseUrl: string, page: number, accumulated: Commit[]): Observable { + const pageUrl = `${baseUrl}&page=${page}`; + return this.http.get(pageUrl, { headers: this.headers, observe: "response" }).pipe( + switchMap((response: HttpResponse) => { + const nodes = response.body || []; + const commits = nodes.map((n) => Commit.withGitlabJSON(n)); + const allCommits = [...accumulated, ...commits]; + + const nextPage = response.headers.get("x-next-page"); + if (nextPage && nextPage.trim() !== "" && nodes.length > 0) { + return this.fetchCommitsPage(baseUrl, parseInt(nextPage, 10), allCommits); + } + return of(allCommits); + }), + catchError((err) => { + console.error(`Error fetching commits page ${page}`, err); + return of(accumulated); + }), + ); + } + + private mapProjectToRepository(project: any, parentUrl?: string): Repository { + const resolvedParent = project.forked_from_project?.web_url || parentUrl || undefined; + const isFork = Boolean(project.forked_from_project || resolvedParent); + const repo = new Repository( + project.web_url, + project.path_with_namespace || project.name, + undefined, + undefined, + undefined, + project.description, + isFork, + resolvedParent, + ); + repo.provider = "gitlab"; + return repo; + } + + private extractRepoPathAndOrigin(repoUrl: string): { origin: string; path: string } { + if (!repoUrl) { + return { origin: this.getBaseOrigin(), path: "" }; + } + let clean = repoUrl.trim(); + // Handle SSH format e.g. git@gitlab.com:group/subgroup/project.git + const sshMatch = clean.match(/^git@([^:]+):(.+?)(?:\.git|\/)?$/); + if (sshMatch) { + return { + origin: `https://${sshMatch[1]}`, + path: sshMatch[2].replace(/^\//, "").replace(/\/$/, ""), + }; + } + + try { + const url = new URL(clean.startsWith("http") ? clean : `https://${this.gitlabAuthService.instanceHost}/${clean}`); + const path = url.pathname + .replace(/^\//, "") + .replace(/\.git$/, "") + .replace(/\/$/, ""); + return { origin: url.origin, path }; + } catch (e) { + return { + origin: this.getBaseOrigin(), + path: clean + .replace(/^\//, "") + .replace(/\.git$/, "") + .replace(/\/$/, ""), + }; + } + } + + private getBaseOrigin(): string { + const host = this.gitlabAuthService.instanceHost || "gitlab.com"; + return `https://${host.replace(/^https?:\/\//i, "").replace(/\/.*$/, "")}`; + } + + private getBaseApi(): string { + return `${this.getBaseOrigin()}/api/v4`; + } +} diff --git a/src/app/services/utils.ts b/src/app/services/utils.ts index 28e3f996..2f20a7e0 100644 --- a/src/app/services/utils.ts +++ b/src/app/services/utils.ts @@ -103,7 +103,7 @@ export class Utils { new Date(Math.min(interval[0].getTime(), date.getTime())), new Date(Math.max(interval[1].getTime(), date.getTime())), ], - [dates[0], dates[1]] + [dates[0], dates[1]], ); } @@ -198,7 +198,7 @@ export class Utils { const charsToShow = Math.max(1, maxLength - 3); const frontChars = Math.ceil(charsToShow / 2); const backChars = Math.floor(charsToShow / 2); - return str.substring(0, frontChars) + '...' + str.substring(str.length - backChars); + return str.substring(0, frontChars) + "..." + str.substring(str.length - backChars); } /** @@ -215,17 +215,35 @@ export class Utils { if (owner) { clean = clean.replace(new RegExp(`^${owner}[-_]`, "i"), ""); } - clean = clean.replace( - /[-_][a-zA-Z0-9_]*(?:squelette|template|skeleton)[a-zA-Z0-9_]*$/i, - "" - ); - clean = clean.replace( - /[-_](?:squelette|template|skeleton)[a-zA-Z0-9_]*$/i, - "" - ); + clean = clean.replace(/[-_][a-zA-Z0-9_]*(?:squelette|template|skeleton)[a-zA-Z0-9_]*$/i, ""); + clean = clean.replace(/[-_](?:squelette|template|skeleton)[a-zA-Z0-9_]*$/i, ""); clean = clean.replace(/[-_]+$/, ""); return clean.replace(/_/g, "-").toLowerCase(); } + static getValueWithToken(token: string, text: string): string { + if (!token || !text) return null; + let regex = new RegExp(`(?<=${token}).*`); + let value = text.match(regex); + return value ? value[0].trim() : null; + } + + static getNameFromIdentity(identity: any): string { + if (!identity) return ""; + return [identity.last_name, identity.first_name].filter(Boolean).join(" "); + } + + static getNameFromReadMe(readme: string, lastNameToken = "Nom", firstNameToken = "Prénom"): string { + if (!readme) return null; + let lastName = this.getValueWithToken(`${lastNameToken}.*:`, readme); + let firstName = this.getValueWithToken(`${firstNameToken}.*:`, readme); + return [lastName, firstName].filter(Boolean).join(" "); + } + + static getTPGroupFromReadMe(readme: string): string { + if (!readme) return null; + return this.getValueWithToken("-\\s*\\[\\S\\]", readme); + } + constructor() {} } diff --git a/src/app/shared/ui/dev-bar/dev-bar.component.html b/src/app/shared/ui/dev-bar/dev-bar.component.html index e49b8218..93f35f09 100644 --- a/src/app/shared/ui/dev-bar/dev-bar.component.html +++ b/src/app/shared/ui/dev-bar/dev-bar.component.html @@ -140,16 +140,16 @@
- + diff --git a/src/app/shared/ui/dev-bar/dev-bar.component.ts b/src/app/shared/ui/dev-bar/dev-bar.component.ts index 944002d2..dc9bae93 100644 --- a/src/app/shared/ui/dev-bar/dev-bar.component.ts +++ b/src/app/shared/ui/dev-bar/dev-bar.component.ts @@ -142,7 +142,7 @@ export class DevBarComponent implements OnInit, OnDestroy { toggleGitlabCloud(): void { this.devFlagsService.toggleGitlabCloud(); const state = this.devFlagsService.gitlabCloudEnabled ? "activé" : "désactivé"; - this.toastService.success("Feature Flag", `GitLab Cloud ${state}`); + this.toastService.success("Feature Flag", `GitLab ${state}`); } toggleGitlabCustom(): void { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 0cc4dada..99848196 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -159,6 +159,13 @@ "SORT-BY": "Sort by", "MUST-LOGIN": "You must be logged in to perform this action", "CREATE-ASSIGNMENT": "New Assignment", + "CREATE-ASSIGNMENT-GITHUB": "New GitHub Assignment", + "CREATE-ASSIGNMENT-GITLAB": "New GitLab Assignment", + "CREATE-ASSIGNMENT-CHOOSE": "Choose Git Provider", + "MUST-LOGIN-GITHUB": "Connect a GitHub account to create an assignment", + "MUST-LOGIN-GITLAB": "Connect a GitLab account to create an assignment", + "MUST-LOGIN-ASSIGNMENT-GITHUB": "Connect a GitHub account to access this assignment", + "MUST-LOGIN-ASSIGNMENT-GITLAB": "Connect a GitLab account to access this assignment", "FILTER": { "STATUS": "Status", "COURSE": "Course", @@ -566,12 +573,12 @@ "CHOOSE_PROVIDER": "Choose a platform", "CHOOSE_PLATFORM": "Choose a platform", "PROVIDER_GITHUB": "GitHub", - "PROVIDER_GITLAB": "GitLab Cloud", + "PROVIDER_GITLAB": "GitLab", "PROVIDER_GITLAB_SELF": "Institutional GitLab (e.g. IRIT)", "PLATFORM_GITHUB_TITLE": "GitHub", "PLATFORM_GITHUB_DESC": "GitHub.com (Direct login via Firebase)", - "PLATFORM_GITLAB_TITLE": "GitLab.com", - "PLATFORM_GITLAB_DESC": "GitLab.com Cloud (Direct OAuth 2.0 PKCE)", + "PLATFORM_GITLAB_TITLE": "GitLab", + "PLATFORM_GITLAB_DESC": "GitLab (Direct OAuth 2.0 PKCE)", "PLATFORM_GITLAB_SELF_TITLE": "Self-hosted GitLab", "PLATFORM_GITLAB_SELF_DESC": "Institutional server (IRIT, University - URL & PAT Token)", "OAUTH_FIREBASE_TITLE": "Secure Login via Firebase", diff --git a/src/assets/i18n/fr.json b/src/assets/i18n/fr.json index 2b74c789..1cdd94e1 100644 --- a/src/assets/i18n/fr.json +++ b/src/assets/i18n/fr.json @@ -158,6 +158,13 @@ "SORT-BY": "Trier par", "MUST-LOGIN": "Vous devez être connecté pour effectuer cette action", "CREATE-ASSIGNMENT": "Nouveau devoir", + "CREATE-ASSIGNMENT-GITHUB": "Nouveau devoir GitHub", + "CREATE-ASSIGNMENT-GITLAB": "Nouveau devoir GitLab", + "CREATE-ASSIGNMENT-CHOOSE": "Choisir la forge", + "MUST-LOGIN-GITHUB": "Connectez un compte GitHub pour créer un devoir", + "MUST-LOGIN-GITLAB": "Connectez un compte GitLab pour créer un devoir", + "MUST-LOGIN-ASSIGNMENT-GITHUB": "Connectez un compte GitHub pour accéder à ce devoir", + "MUST-LOGIN-ASSIGNMENT-GITLAB": "Connectez un compte GitLab pour accéder à ce devoir", "FILTER": { "STATUS": "Statut", "COURSE": "Cours", @@ -566,12 +573,12 @@ "CHOOSE_PROVIDER": "Choisir une plateforme", "CHOOSE_PLATFORM": "Choisir une plateforme", "PROVIDER_GITHUB": "GitHub", - "PROVIDER_GITLAB": "GitLab Cloud", + "PROVIDER_GITLAB": "GitLab", "PROVIDER_GITLAB_SELF": "GitLab Institutionnel (ex: IRIT)", "PLATFORM_GITHUB_TITLE": "GitHub", "PLATFORM_GITHUB_DESC": "GitHub.com (Connexion directe via Firebase)", - "PLATFORM_GITLAB_TITLE": "GitLab.com", - "PLATFORM_GITLAB_DESC": "GitLab.com Cloud (OAuth 2.0 PKCE direct)", + "PLATFORM_GITLAB_TITLE": "GitLab", + "PLATFORM_GITLAB_DESC": "GitLab (OAuth 2.0 PKCE direct)", "PLATFORM_GITLAB_SELF_TITLE": "GitLab auto-hébergé", "PLATFORM_GITLAB_SELF_DESC": "Serveur institutionnel (IRIT, Université - URL & Token PAT)", "OAUTH_FIREBASE_TITLE": "Connexion sécurisée via Firebase", diff --git a/src/assets/i18n/ru.json b/src/assets/i18n/ru.json index 39b502b2..49ed11d5 100644 --- a/src/assets/i18n/ru.json +++ b/src/assets/i18n/ru.json @@ -157,6 +157,13 @@ "SORT-BY": "Сортировать по", "MUST-LOGIN": "Для выполнения этого действия необходимо войти в систему", "CREATE-ASSIGNMENT": "Новое задание", + "CREATE-ASSIGNMENT-GITHUB": "Новое задание GitHub", + "CREATE-ASSIGNMENT-GITLAB": "Новое задание GitLab", + "CREATE-ASSIGNMENT-CHOOSE": "Выбрать Git-провайдера", + "MUST-LOGIN-GITHUB": "Подключите GitHub для создания задания", + "MUST-LOGIN-GITLAB": "Подключите GitLab для создания задания", + "MUST-LOGIN-ASSIGNMENT-GITHUB": "Подключите аккаунт GitHub для доступа к этому заданию", + "MUST-LOGIN-ASSIGNMENT-GITLAB": "Подключите аккаунт GitLab для доступа к этому заданию", "FILTER": { "STATUS": "Статус", "COURSE": "Курс", @@ -563,12 +570,12 @@ "CHOOSE_PROVIDER": "Выберите платформу", "CHOOSE_PLATFORM": "Выберите платформу", "PROVIDER_GITHUB": "GitHub", - "PROVIDER_GITLAB": "GitLab Cloud", + "PROVIDER_GITLAB": "GitLab", "PROVIDER_GITLAB_SELF": "Институциональный GitLab (напр. IRIT)", "PLATFORM_GITHUB_TITLE": "GitHub", "PLATFORM_GITHUB_DESC": "GitHub.com (Вход через Firebase)", - "PLATFORM_GITLAB_TITLE": "GitLab.com", - "PLATFORM_GITLAB_DESC": "GitLab.com Cloud (Прямой OAuth 2.0 PKCE)", + "PLATFORM_GITLAB_TITLE": "GitLab", + "PLATFORM_GITLAB_DESC": "GitLab (Прямой OAuth 2.0 PKCE)", "PLATFORM_GITLAB_SELF_TITLE": "GitLab (собственный)", "PLATFORM_GITLAB_SELF_DESC": "Сервер организации (IRIT, Университет - URL и токен PAT)", "OAUTH_FIREBASE_TITLE": "Безопасный вход через Firebase", From 6cc88528e3c2fe8f43e1339c3381289a22fa2dc3 Mon Sep 17 00:00:00 2001 From: Mika Pons Date: Thu, 17 Sep 2026 23:54:38 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A8=20Add=20secure=20remember-me=20to?= =?UTF-8?q?ken=20storage,=20clean=20legacy=20auth=20and=20fix=20sidebar=20?= =?UTF-8?q?multi-forge=20assignments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...08-memorisation-securisee-tokens-forges.md | 77 ++++ docs/adr/README.md | 1 + src/app/app.component.ts | 17 +- .../add-account-modal.component.html | 66 ++++ .../add-account-modal.component.scss | 75 ++++ .../add-account-modal.component.spec.ts | 22 ++ .../add-account-modal.component.ts | 3 +- .../sidebar-settings.component.html | 2 +- .../sidebar-settings.component.ts | 32 +- src/app/models/GitAuthProvider.model.ts | 2 +- src/app/services/github-auth.service.spec.ts | 50 ++- src/app/services/github-auth.service.ts | 150 ++++---- src/app/services/gitlab-auth.service.spec.ts | 16 + src/app/services/gitlab-auth.service.ts | 29 +- .../services/token-storage.service.spec.ts | 99 ++++++ src/app/services/token-storage.service.ts | 329 ++++++++++++++++++ src/app/services/tooltip.service.ts | 10 +- .../shared/ui/tooltip/tooltip.component.html | 2 +- .../shared/ui/tooltip/tooltip.component.scss | 13 +- .../shared/ui/tooltip/tooltip.component.ts | 99 +++--- .../shared/ui/tooltip/tooltip.directive.ts | 3 +- src/assets/i18n/en.json | 4 +- src/assets/i18n/fr.json | 4 +- src/assets/i18n/ru.json | 4 +- 24 files changed, 939 insertions(+), 170 deletions(-) create mode 100644 docs/adr/0008-memorisation-securisee-tokens-forges.md create mode 100644 src/app/services/token-storage.service.spec.ts create mode 100644 src/app/services/token-storage.service.ts 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 index b93155ce..bb34d0fe 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,6 +17,7 @@ Ce répertoire contient l'historique formalisé de toutes les décisions d'archi | [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é` | --- diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 3cb5e0a3..7ac8f332 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -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 { GithubAuthService } from "@services/github-auth.service"; @@ -18,7 +18,7 @@ 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 @@ -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.githubAuthService.loading = false; - if (!this.githubAuthService.isSignedIn()) { - this.githubAuthService.reauthenticate(); - } - } - - /** - * This method is called when the component is destroyed. - * Disconnects the user - */ - ngOnDestroy(): void { - this.githubAuthService.signOut(); } } diff --git a/src/app/components/nav-layouts/sidebar-settings/accounts/add-account-modal/add-account-modal.component.html b/src/app/components/nav-layouts/sidebar-settings/accounts/add-account-modal/add-account-modal.component.html index 7173cdaf..33ae8ae1 100644 --- a/src/app/components/nav-layouts/sidebar-settings/accounts/add-account-modal/add-account-modal.component.html +++ b/src/app/components/nav-layouts/sidebar-settings/accounts/add-account-modal/add-account-modal.component.html @@ -192,6 +192,39 @@
+ +
+ + + + + + + + + +
+