diff --git a/README.md b/README.md index ac2b7c5..2f51cbe 100644 --- a/README.md +++ b/README.md @@ -2,31 +2,65 @@ ![alt text](image.png) -## How to build +RNCP Planner is a helper tool for 42 students. At 42, progression is project-based: each validated project grants XP, and XP increases your level. This project lets you simulate planned project validations to estimate your future level and track your RNCP progression. -### Development +## Running RNCP Planner locally -1. Edit `flask/.env` and set `CLIENT_ID`, `CLIENT_SECRET`, `SECOND_CLIENT_SECRET`, and `REDIRECT_URI` -2. Replace the redirect url in the environment.ts -3. Execute: + +Click to expand local setup instructions + +### Prerequisites + +Before you begin, ensure you have the following installed on your system: + +- [Docker](https://www.docker.com/) + +### 1. Create a 42 API key + +To use 42 OAuth and retrieve user projects, you must create an API key from the 42 intra. + +1. Visit the 42 API [OAuth page](https://profile.intra.42.fr/oauth/applications). +2. Create a new application and use the following callback URL: + +```text +http://localhost:3000/auth/callback/42 +``` + +### 2. Configure environment variables + +Create or edit `flask/.env` and fill it with the variables used by this project. + +#### Example `flask/.env` file + +```env +# UID of your 42 OAuth application +CLIENT_ID="u-s4t2ud..." + +# Secret of your 42 OAuth application +CLIENT_SECRET="s-s4t2ud..." + +# next secret +SECOND_CLIENT_SECRET="s-s4t2ud..." + +# Must match your 42 OAuth application callback URL +REDIRECT_URI="http://localhost:3000/auth/callback/42" +``` + +> [!TIP] +> For local deployment, `CLIENT_ID`, `CLIENT_SECRET`, `SECOND_CLIENT_SECRET` and `REDIRECT_URI` are required. + +### 3. Run RNCP Planner + +With everything set up, run: ```bash docker compose run angular npm install docker compose up -d ``` -### Production +Access the app at http://localhost:3000. -0. Edit flask/.env -1. Exec: -```bash -docker compose run angular sh -c "rm -rf node_modules && npm install && ng build" -``` +> [!NOTE] +> If recent JSON/config changes do not appear immediately, force a browser hard refresh (`Ctrl+Shift+R`). -2. Uncomment the prod server on nginx.conf - 2.1 Set SSL certificates -3. Replace redirect url in the environment.ts -4. Exec: -```bash -docker compose up -d nginx flask -``` \ No newline at end of file + \ No newline at end of file diff --git a/angular/src/app/app.routes.ts b/angular/src/app/app.routes.ts index 627718d..ba97868 100644 --- a/angular/src/app/app.routes.ts +++ b/angular/src/app/app.routes.ts @@ -28,4 +28,8 @@ export const routes: Routes = [ path: 'auth/callback', component: LoginComponent, }, + { + path: 'auth/callback/:campusId', + component: LoginComponent, + }, ]; diff --git a/angular/src/app/login/login.component.ts b/angular/src/app/login/login.component.ts index cd6c814..efba17e 100644 --- a/angular/src/app/login/login.component.ts +++ b/angular/src/app/login/login.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { environment } from '../../environments/environment.development'; +import { environment } from '../../environments/environment'; import { AuthService } from '../services/auth.service'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; @@ -13,14 +13,14 @@ import { TranslateModule } from '@ngx-translate/core'; styleUrl: './login.component.css', }) export class LoginComponent implements OnInit { - auth_url = environment.auth_url; - repo_url = 'https://github.com/d-r-e/rncp'; + auth_url = environment.auth_url; + repo_url = 'https://github.com/d-r-e/rncp'; constructor( private route: ActivatedRoute, private router: Router, private authService: AuthService - ) {} + ) { } ngOnInit(): void { // Redirect to home if already logged in @@ -32,7 +32,6 @@ export class LoginComponent implements OnInit { this.route.queryParams.subscribe(params => { if (params['code']) { this.authService.login(params['code']); - this.router.navigate(['/rncp']); } }); } diff --git a/angular/src/app/path/path.component.ts b/angular/src/app/path/path.component.ts index e935e47..b9d3875 100644 --- a/angular/src/app/path/path.component.ts +++ b/angular/src/app/path/path.component.ts @@ -9,186 +9,191 @@ import { BlockComponent } from './block/block.component'; // from https://42evaluators.com/calculator const levelsXp = [ - 0, 462, 2688, 5885, 11777, 29217, 46255, 63559, 74340, 85483, 95000, 105630, - 124446, 145782, 169932, 197316, 228354, 263508, 303366, 348516, 399672, 457632, - 523320, 597786, 682164, 777756, 886074, 1008798, 1147902, 1305486, 1484070]; + 0, 462, 2688, 5885, 11777, 29217, 46255, 63559, 74340, 85483, 95000, 105630, + 124446, 145782, 169932, 197316, 228354, 263508, 303366, 348516, 399672, 457632, + 523320, 597786, 682164, 777756, 886074, 1008798, 1147902, 1305486, 1484070]; interface Internship { - name: string; - baseXP: number; - grade: number; + name: string; + baseXP: number; + grade: number; } @Component({ - selector: 'app-path', - templateUrl: './path.component.html', - styleUrl: './path.component.css', + selector: 'app-path', + templateUrl: './path.component.html', + styleUrl: './path.component.css', }) export class PathComponent implements OnInit { - events: number = 0; - rncp: number = 6; - blocks: Block[] = []; - projects: ProjectUser[] = []; - requiredLevel: number = 17; - requiredEvents: number = 10; - internships: number = 0; - requiredInternships: number = 2; - plannedInternships: Internship[] = []; - path: 'web' | 'apps' | 'sec' | 'ai' = 'ai'; - selectedInternship?: Internship; // To store the currently selected internship - selectedGrade: number = 100; // Default grade value - inputValue: number = 100; - - @ViewChildren(BlockComponent) blockComponents!: QueryList; - - availableInternships: Internship[] = - [ - { name: 'Work Experience I', baseXP: 42000, grade: 100 }, - { name: 'Work Experience II', baseXP: 63000, grade: 100 }, - ] - showInternshipForm: boolean = false; - - constructor( - private authService: AuthService, - private router: Router, - private http: HttpClient, - private translate: TranslateService - ) { - const internshipProjects = this.authService.getInternships(); - this.availableInternships = this.availableInternships.filter(i=> !internshipProjects.find(p=> p.name === i.name)); - this.internships = internshipProjects.length; - - this.authService.getEvents().subscribe((events: CursusEvent[]) => { - this.events = events.length; - }); - - if (this.authService.me) { - this.projects = this.authService.me.projects_users.filter( - (project: ProjectUser) => - project.cursus_ids.includes(21) && project['validated?'] - ); - } - } - - ngOnInit(): void { - this.setRNCP(7); - } - - loadData() { - this.http.get(`assets/${this.rncp}-${this.path}.json`).subscribe(response => { - this.blocks = response as Block[]; - }); - } - - onInputChange(event: Event): void { - const inputElement = event.target as HTMLInputElement; - const newValue = Number(inputElement.value); - this.selectedGrade = newValue; - } - - selectInternship(internship: Internship) { - this.selectedInternship = internship; - } - - addPlannedInternship() { - if (!this.selectedInternship) return; - if (this.selectedGrade < 100 || this.selectedGrade > 125) return; - - const existingInternship = this.plannedInternships.find( - i => i.name === this.selectedInternship?.name - ); - - if (!existingInternship) { - this.plannedInternships.push({ - ...this.selectedInternship, - grade: this.selectedGrade - }); - this.updatePlannedXP(); - this.internships++; - } else { - existingInternship.grade = this.selectedGrade; - this.updatePlannedXP(); - } - - this.selectedInternship = undefined; - this.selectedGrade = 100; - } - - updatePlannedXP() { - const totalPlannedXP = this.plannedInternships.reduce((total, internship) => { - const xp = internship.baseXP * (internship.grade ? internship.grade / 100 : 1); - return total + xp; - }, 0); - - // Here you could update your UI or any other relevant property with the calculated XP - console.log('Total Planned XP:', totalPlannedXP); - } - - setRNCP(level: number) { - this.rncp = level; - this.requiredLevel = level == 6 ? 17 : 21; - this.requiredEvents = level == 6 ? 10 : 15; - this.path = level == 6 ? 'web' : 'sec'; - this.loadData(); - } - - setPath(path: 'web' | 'apps' | 'sec' | 'ai') { - this.path = path; - this.loadData(); - } - - getLevel() { - return this.authService.getLevel(); - } - - private getUniquePlannedProjectsXp(): number { - // Collect all planned projects from all blocks, deduplicated by project ID - const uniquePlannedProjects = new Map(); // projectId -> XP - - // Iterate through all block components to get their planned projects - if (this.blockComponents) { - this.blockComponents.forEach((blockComponent: BlockComponent) => { - blockComponent.planned_projects.forEach((plannedProject: ProjectUser) => { - const projectId = plannedProject.project.id; - const projectXp = plannedProject.occurrence; - - // Store the maximum XP if the same project is planned with different grades - const currentXp = uniquePlannedProjects.get(projectId); - if (currentXp === undefined || projectXp > currentXp) { - uniquePlannedProjects.set(projectId, projectXp); - } - }); - }); - } - - // Sum up the XP from unique planned projects - return Array.from(uniquePlannedProjects.values()).reduce((acc, xp) => acc + xp, 0); - } - - getPlannedLevel() { - const startLevel = this.getLevel(); - - // Get deduplicated XP from all planned projects across blocks - let plannedXp = this.getUniquePlannedProjectsXp(); - - // Add internship XP - plannedXp += this.plannedInternships.map((internship: Internship) => internship.baseXP * internship.grade / 100).reduce((acc, xp) => acc + xp, 0); - - const levelDown = Math.floor(startLevel); - const levelUp = Math.ceil(startLevel); - const levelXpTotal = levelsXp[levelUp] - levelsXp[levelDown]; - const currentXp = levelsXp[levelDown] + (levelXpTotal * (startLevel - Math.floor(startLevel))); - - let finalXp = currentXp + plannedXp; - - let i = 0; - for (; i < levelsXp.length; i++) { - if (levelsXp[i] > finalXp) break; - } - - const maxXp = levelsXp[i] - levelsXp[i - 1]; - finalXp = finalXp - levelsXp[i - 1]; - - return i - 1 + (finalXp / maxXp); - } + events: number = 0; + rncp: number = 6; + blocks: Block[] = []; + projects: ProjectUser[] = []; + requiredLevel: number = 17; + requiredEvents: number = 10; + internships: number = 0; + requiredInternships: number = 2; + plannedInternships: Internship[] = []; + path: 'web' | 'apps' | 'sec' | 'ai' = 'ai'; + selectedInternship?: Internship; // To store the currently selected internship + selectedGrade: number = 100; // Default grade value + inputValue: number = 100; + + @ViewChildren(BlockComponent) blockComponents!: QueryList; + + availableInternships: Internship[] = + [ + { name: 'Work Experience I', baseXP: 42000, grade: 100 }, + { name: 'Work Experience II', baseXP: 63000, grade: 100 }, + ] + showInternshipForm: boolean = false; + + constructor( + private authService: AuthService, + private router: Router, + private http: HttpClient, + private translate: TranslateService + ) { + const internshipProjects = this.authService.getInternships(); + this.availableInternships = this.availableInternships.filter(i => !internshipProjects.find(p => p.name === i.name)); + this.internships = internshipProjects.length; + + this.authService.getEvents().subscribe((events: CursusEvent[]) => { + this.events = events.length; + }); + + if (this.authService.me) { + this.projects = this.authService.me.projects_users.filter( + (project: ProjectUser) => + project.cursus_ids.includes(21) && project['validated?'] + ); + } + } + + ngOnInit(): void { + this.setRNCP(7); + } + + loadData() { + this.http.get(`/assets/${this.rncp}-${this.path}.json`).subscribe({ + next: (response: Block[]) => { + this.blocks = response; + }, + error: () => { + this.blocks = []; + }, + }); + } + + onInputChange(event: Event): void { + const inputElement = event.target as HTMLInputElement; + const newValue = Number(inputElement.value); + this.selectedGrade = newValue; + } + + selectInternship(internship: Internship) { + this.selectedInternship = internship; + } + + addPlannedInternship() { + if (!this.selectedInternship) return; + if (this.selectedGrade < 100 || this.selectedGrade > 125) return; + + const existingInternship = this.plannedInternships.find( + i => i.name === this.selectedInternship?.name + ); + + if (!existingInternship) { + this.plannedInternships.push({ + ...this.selectedInternship, + grade: this.selectedGrade + }); + this.updatePlannedXP(); + this.internships++; + } else { + existingInternship.grade = this.selectedGrade; + this.updatePlannedXP(); + } + + this.selectedInternship = undefined; + this.selectedGrade = 100; + } + + updatePlannedXP() { + const totalPlannedXP = this.plannedInternships.reduce((total, internship) => { + const xp = internship.baseXP * (internship.grade ? internship.grade / 100 : 1); + return total + xp; + }, 0); + + // Here you could update your UI or any other relevant property with the calculated XP + console.log('Total Planned XP:', totalPlannedXP); + } + + setRNCP(level: number) { + this.rncp = level; + this.requiredLevel = level == 6 ? 17 : 21; + this.requiredEvents = level == 6 ? 10 : 15; + this.path = level == 6 ? 'web' : 'sec'; + this.loadData(); + } + + setPath(path: 'web' | 'apps' | 'sec' | 'ai') { + this.path = path; + this.loadData(); + } + + getLevel() { + return this.authService.getLevel(); + } + + private getUniquePlannedProjectsXp(): number { + // Collect all planned projects from all blocks, deduplicated by project ID + const uniquePlannedProjects = new Map(); // projectId -> XP + + // Iterate through all block components to get their planned projects + if (this.blockComponents) { + this.blockComponents.forEach((blockComponent: BlockComponent) => { + blockComponent.planned_projects.forEach((plannedProject: ProjectUser) => { + const projectId = plannedProject.project.id; + const projectXp = plannedProject.occurrence; + + // Store the maximum XP if the same project is planned with different grades + const currentXp = uniquePlannedProjects.get(projectId); + if (currentXp === undefined || projectXp > currentXp) { + uniquePlannedProjects.set(projectId, projectXp); + } + }); + }); + } + + // Sum up the XP from unique planned projects + return Array.from(uniquePlannedProjects.values()).reduce((acc, xp) => acc + xp, 0); + } + + getPlannedLevel() { + const startLevel = this.getLevel(); + + // Get deduplicated XP from all planned projects across blocks + let plannedXp = this.getUniquePlannedProjectsXp(); + + // Add internship XP + plannedXp += this.plannedInternships.map((internship: Internship) => internship.baseXP * internship.grade / 100).reduce((acc, xp) => acc + xp, 0); + + const levelDown = Math.floor(startLevel); + const levelUp = Math.ceil(startLevel); + const levelXpTotal = levelsXp[levelUp] - levelsXp[levelDown]; + const currentXp = levelsXp[levelDown] + (levelXpTotal * (startLevel - Math.floor(startLevel))); + + let finalXp = currentXp + plannedXp; + + let i = 0; + for (; i < levelsXp.length; i++) { + if (levelsXp[i] > finalXp) break; + } + + const maxXp = levelsXp[i] - levelsXp[i - 1]; + finalXp = finalXp - levelsXp[i - 1]; + + return i - 1 + (finalXp / maxXp); + } } diff --git a/angular/src/assets/7-sec.json b/angular/src/assets/7-sec.json index 049147a..32e09cf 100644 --- a/angular/src/assets/7-sec.json +++ b/angular/src/assets/7-sec.json @@ -6,35 +6,15 @@ "min_projects": 3, "projects": [ { - "id": 1430, - "name": "famine", + "id": 39001, + "name": "cybersecurity", "xp": 9450 }, - { - "id": 1443, - "name": "pestilence", - "xp": 15750 - }, { "id": 1404, "name": "snow-crash", "xp": 9450 }, - { - "id": 1840, - "name": "ft_malcolm", - "xp": 6000 - }, - { - "id": 1451, - "name": "ft_ssl_md5", - "xp": 9450 - }, - { - "id": 1405, - "name": "Darkly", - "xp": 6000 - }, { "id": 1417, "name": "Rainfall", @@ -51,39 +31,64 @@ "xp": 11500 }, { - "id": 1447, - "name": "ft_shield", + "id": 2393, + "name": "UnleashTheBox", "xp": 15750 }, + { + "id": 39002, + "name": "tinky-winkey", + "xp": 16800 + }, + { + "id": 39003, + "name": "dr-quine", + "xp": 2520 + }, { "id": 1419, - "name": "Woody Woodpacker", + "name": "woody-woodpacker", "xp": 9450 }, { - "id": 2346, - "name": "Piscine Cybersecurity", - "xp": 0 + "id": 1447, + "name": "ft_shield", + "xp": 15750 }, { - "id": 2393, - "name": "UnleashTheBox", - "xp": 15750 + "id": 1430, + "name": "famine", + "xp": 9450 }, { - "id": 2395, - "name": "Active Connect", + "id": 1443, + "name": "pestilence", "xp": 15750 }, { - "id": 2527, - "name": "MicroForensX", - "xp": 9450 + "id": 39004, + "name": "war", + "xp": 35700 + }, + { + "id": 39005, + "name": "death", + "xp": 35700 + }, + { + "id": 1840, + "name": "ft_malcolm", + "xp": 6000 + }, + { + "id": 1400, + "name": "ft_nmap", + "xp": 15750 }, { - "id": 2529, - "name": "ActiveTechTales", - "xp": 15750 + "id": 1405, + "name": "Darkly", + "xp": 6000 } ] }, @@ -192,7 +197,7 @@ "xp": 22450 }, { - "id": 1458, + "id": 1458, "name": "Doom Nukem", "xp": 15750 }, @@ -276,24 +281,24 @@ }, { "id": 2520, - "name":"Active Discovery", - "xp": 15750 + "name": "Active Discovery", + "xp": 15750 }, { "id": 2521, "name": "Automatic Directory", - "xp": 9450 + "xp": 9450 }, { "id": 2522, "name": "Administrative Directory", - "xp":9450 + "xp": 9450 }, { "id": 2523, "name": "Accessible Directory", - "xp": 9450 + "xp": 9450 } ] } -] +] \ No newline at end of file diff --git a/angular/src/environments/environment.development.ts b/angular/src/environments/environment.development.ts index 54fd2aa..9cdf3c1 100644 --- a/angular/src/environments/environment.development.ts +++ b/angular/src/environments/environment.development.ts @@ -1,6 +1,8 @@ +const redirectUri = encodeURIComponent(`${window.location.origin}/auth/callback/42`); + export const environment = { production: false, auth_url: - 'https://api.intra.42.fr/oauth/authorize?client_id=&redirect_uri=&response_type=code', + `https://api.intra.42.fr/oauth/authorize?client_id=u-s4t2ud-7282b484449a021720b0c6dbd86b212e0d9cd7980348786659e3ebf8f81cf718&redirect_uri=${redirectUri}&response_type=code`, api_url: 'https://api.intra.42.fr', }; diff --git a/angular/src/environments/environment.ts b/angular/src/environments/environment.ts index f274e5e..c969510 100644 --- a/angular/src/environments/environment.ts +++ b/angular/src/environments/environment.ts @@ -1 +1,8 @@ -export const environment = {}; +const redirectUri = encodeURIComponent(`${window.location.origin}/auth/callback/42`); + +export const environment = { + production: true, + auth_url: + `https://api.intra.42.fr/oauth/authorize?client_id=u-s4t2ud-7282b484449a021720b0c6dbd86b212e0d9cd7980348786659e3ebf8f81cf718&redirect_uri=${redirectUri}&response_type=code`, + api_url: 'https://api.intra.42.fr', +}; diff --git a/docker-compose.yml b/docker-compose.yml index 387097b..888200c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: context: . dockerfile: nginx.Dockerfile ports: - - "80:80" + - "3000:80" - "127.0.0.1:4200:4200" depends_on: - flask diff --git a/nginx.conf b/nginx.conf index e5b8675..676cd9d 100644 --- a/nginx.conf +++ b/nginx.conf @@ -5,6 +5,7 @@ http { server { listen 80; + include /etc/nginx/mime.types; server_name localhost; server_tokens off; @@ -15,39 +16,11 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } - # angular dev location / { - proxy_pass http://angular:4200; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # for websockets: - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; } } - - # server { - # listen 80; - # # regular mime types - # include /etc/nginx/mime.types; - # server_name localhost; - # server_tokens off; - - # location /api { - # proxy_pass http://flask:5000; - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # } - - # location / { - # root /usr/share/nginx/html; - # index index.html index.htm; - # try_files $uri $uri/ /index.html; - # } - # } } \ No newline at end of file