diff --git a/README.md b/README.md
index ac2b7c5..2f51cbe 100644
--- a/README.md
+++ b/README.md
@@ -2,31 +2,65 @@

-## 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