diff --git a/assets/images/service_uphf.png b/assets/images/service_uphf.png new file mode 100644 index 00000000..e50b79ec Binary files /dev/null and b/assets/images/service_uphf.png differ diff --git a/package-lock.json b/package-lock.json index 8788495d..00d65af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "scolengo-api": "^3.0.5", "text-encoding": "^0.7.0", "turbawself": "^1.1.1", + "uphf-api": "^2.1.3", "zustand": "^4.5.2" }, "devDependencies": { @@ -17768,6 +17769,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uphf-api": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/uphf-api/-/uphf-api-2.1.3.tgz", + "integrity": "sha512-/zSB3Gflu/p/FX/ydaDm46kncW4gnxzo/7zTwNArTNfRNPyl91LB0Coqcj2Vpv7eQJGV4MPdpmiblif+HuhCoA==", + "license": "GPL-3.0-only", + "engines": { + "node": ">=18" + } + }, "node_modules/upper-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", diff --git a/package.json b/package.json index 7ddfbb00..22f0f8a5 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "scolengo-api": "^3.0.5", "text-encoding": "^0.7.0", "turbawself": "^1.1.1", + "uphf-api": "^2.1.3", "zustand": "^4.5.2" }, "devDependencies": { diff --git a/src/router/helpers/types.ts b/src/router/helpers/types.ts index 1f63f23b..898fd609 100644 --- a/src/router/helpers/types.ts +++ b/src/router/helpers/types.ts @@ -54,6 +54,7 @@ export type RouteParameters = { UnivRennes2_Login: undefined; UnivLimoges_Login: undefined; UnivSorbonneParisNord_login: undefined; + UnivUphf_Login: undefined; // login.skolengo SkolengoAuthenticationSelector: undefined; diff --git a/src/router/screens/login/identityProvider.ts b/src/router/screens/login/identityProvider.ts index 9e08dea8..3db76bd2 100644 --- a/src/router/screens/login/identityProvider.ts +++ b/src/router/screens/login/identityProvider.ts @@ -5,10 +5,11 @@ import UnivRennes1_Login from "@/views/login/IdentityProvider/providers/UnivRenn import UnivLimoges_Login from "@/views/login/IdentityProvider/providers/UnivLimoges"; import UnivRennes2_Login from "@/views/login/IdentityProvider/providers/UnivRennes2"; import UnivSorbonneParisNord_login from "@/views/login/IdentityProvider/providers/UnivSorbonneParisNord"; +import UnivUphf_Login from "@/views/login/IdentityProvider/providers/UnivUphf"; export default [ createScreen("IdentityProviderSelector", IdentityProviderSelector, { - headerTitle: "Fournisseur d'identité", + headerTitle: "Universités et autres", headerBackVisible: true }), @@ -32,5 +33,9 @@ export default [ headerTitle: "Université Sorbonne Paris Nord", }), + createScreen("UnivUphf_Login", UnivUphf_Login, { + headerBackVisible: true, + headerTitle: "Université Polytechnique Hauts-de-France", + }), ] as const; \ No newline at end of file diff --git a/src/services/news.ts b/src/services/news.ts index 269e7fcf..e174ae82 100644 --- a/src/services/news.ts +++ b/src/services/news.ts @@ -4,6 +4,7 @@ import type { Information } from "./shared/Information"; import { checkIfSkoSupported } from "./skolengo/default-personalization"; import { error } from "@/utils/logger/logger"; import { newsRead } from "pawnote"; +import { ca } from "date-fns/locale"; /** * Updates the state and cache for the news. @@ -30,6 +31,12 @@ export async function updateNewsInCache (account: T): Promis useNewsStore.getState().updateInformations(informations); break; } + case AccountService.UPHF: { + const { getNews } = await import("./uphf/data/news"); + const informations = await getNews(account); + useNewsStore.getState().updateInformations(informations); + break; + } default: { throw new Error("Service not implemented."); } @@ -48,6 +55,9 @@ export async function setNewsRead (account: T, message: Info case AccountService.Local: { break; } + case AccountService.UPHF: { + break; + } default: { throw new Error("Service not implemented."); } diff --git a/src/services/reload-account.ts b/src/services/reload-account.ts index 1b05f692..6de47b21 100644 --- a/src/services/reload-account.ts +++ b/src/services/reload-account.ts @@ -37,6 +37,10 @@ export async function reload (account: T): Promise; + } default: { throw new Error("Service not implemented."); } diff --git a/src/services/shared/errors.ts b/src/services/shared/errors.ts index 4bfd5a46..a0f0d602 100644 --- a/src/services/shared/errors.ts +++ b/src/services/shared/errors.ts @@ -1,5 +1,5 @@ export class ErrorServiceUnauthenticated extends Error { - constructor (service: "pronote"|"ARD"|"turboself"|"skolengo") { + constructor (service: "pronote"|"ARD"|"turboself"|"skolengo"|"UPHF") { super(`${service}: "account.instance" is not defined, you need to authenticate first.`); this.name = "ErrorServiceUnauthenticated"; } diff --git a/src/services/timetable.ts b/src/services/timetable.ts index 79a986cd..eb960057 100644 --- a/src/services/timetable.ts +++ b/src/services/timetable.ts @@ -30,6 +30,12 @@ export async function updateTimetableForWeekInCache (account useTimetableStore.getState().updateClasses(epochWeekNumber, timetable); break; } + case AccountService.UPHF: { + const { getTimetableForWeek } = await import("./uphf/data/timetable"); + const timetable = await getTimetableForWeek(account, epochWeekNumber); + useTimetableStore.getState().updateClasses(epochWeekNumber, timetable); + break; + } default: { throw new Error("Service not implemented."); } diff --git a/src/services/uphf/data/news.ts b/src/services/uphf/data/news.ts new file mode 100644 index 00000000..e87d6207 --- /dev/null +++ b/src/services/uphf/data/news.ts @@ -0,0 +1,22 @@ +import { UphfAccount } from "@/stores/account/types"; +import { Information } from "../../shared/Information"; +import { type ActualitiesResponse, UPHF } from "uphf-api"; +import { AttachmentType } from "@/services/shared/Attachment"; + +const parseInformation = (i: ActualitiesResponse): Information => ({ + id: i.pubDate, + title: i.title, + date: new Date(i.pubDate), + acknowledged: false, + attachments: [{"name": i.title,"type":"link" as AttachmentType, "url": i.link}], + content: i.content, + author: "UPHF Actualités", + category: "Actualités", + read: false, + ref: i, +}); + +export const getNews = async (account: UphfAccount): Promise => { + const news = await UPHF.getActualities(); + return news.map(parseInformation); +}; diff --git a/src/services/uphf/data/timetable.ts b/src/services/uphf/data/timetable.ts new file mode 100644 index 00000000..c4772b85 --- /dev/null +++ b/src/services/uphf/data/timetable.ts @@ -0,0 +1,38 @@ +import type { UphfAccount } from "@/stores/account/types"; +import type { Timetable, TimetableClass} from "../../shared/Timetable"; +import { weekNumberToDateRange } from "@/utils/epochWeekNumber"; +import type { EventResponse } from "uphf-api"; +import { ErrorServiceUnauthenticated } from "@/services/shared/errors"; + +const decodeTimetableClass = (c: EventResponse): TimetableClass => ({ + subject: c.course.label, + id: c.id, + type: "lesson", + title: c.course.label, + startTimestamp: new Date(c.startDateTime).getTime(), + endTimestamp: new Date(c.endDateTime).getTime(), + room: c.course.online ? "En ligne" : c.rooms.map((room: any) => room.label).join(", "), + teacher: c.teachers.map((teacher: any) => teacher.displayname).join(", "), + backgroundColor: c.course.color, + source: "UPHF", +}); + +export const getTimetableForWeek = async (account: UphfAccount, weekNumber: number): Promise => { + if (!account.instance) + throw new ErrorServiceUnauthenticated("UPHF"); + + const timetable = await account.instance.getSchedule({startDate: weekNumberToDateRange(weekNumber).start.toISOString().split("T")[0], endDate:weekNumberToDateRange(weekNumber).end.toISOString().split("T")[0]}); + const eventsList = timetable.plannings.flatMap((planning) => + planning.events.map((event: EventResponse) => ({ + id: event.id, + startDateTime: event.startDateTime, + endDateTime: event.endDateTime, + course: event.course, + rooms: event.rooms.length >= 1 ? event.rooms : [{id: null, label: null, type: null}], + teachers: event.teachers.length >= 1 ? event.teachers : [{id: null, displayname: null, email: null}], + group: event.groups.length >= 1 ? event.groups : [{id: null, label: null}], + })) + ); + + return await eventsList.map(decodeTimetableClass); // TODO for Papillon team: add the group to the timetable +}; \ No newline at end of file diff --git a/src/services/uphf/default-personalization.ts b/src/services/uphf/default-personalization.ts new file mode 100644 index 00000000..91cf3d7c --- /dev/null +++ b/src/services/uphf/default-personalization.ts @@ -0,0 +1,24 @@ +import type { Personalization } from "@/stores/account/types"; +import { defaultTabs } from "@/consts/DefaultTabs"; +import type pronote from "pawnote"; + +import colors from "@/utils/data/colors.json"; + +const defaultUphfTabs = [ + "Home", + "Lessons", + "News", +] as typeof defaultTabs[number]["tab"][]; + +const defaultPersonalization = async (): Promise> => { + return { + color: colors[0], + magicEnabled: true, + tabs: defaultTabs.filter(current => defaultUphfTabs.includes(current.tab)).map((tab, index) => ({ + name: tab.tab, + enabled: index <= 4 + })) + }; +}; + +export default defaultPersonalization; diff --git a/src/services/uphf/reload-uphf.ts b/src/services/uphf/reload-uphf.ts new file mode 100644 index 00000000..d5cd5dbc --- /dev/null +++ b/src/services/uphf/reload-uphf.ts @@ -0,0 +1,14 @@ +import type { UphfAccount } from "@/stores/account/types"; +import type { Reconnected } from "../reload-account"; +import { authWithRefreshToken } from "uphf-api"; + +export const reloadInstance = async (authentication: UphfAccount["authentication"]): Promise> => { + const session = await authWithRefreshToken({ refreshAuthToken: authentication.refreshAuthToken }); + + return { + instance: session, + authentication: { + refreshAuthToken: session.userData.refreshAuthToken + } + }; +}; diff --git a/src/stores/account/types.ts b/src/stores/account/types.ts index 983e2886..a5446c8d 100644 --- a/src/stores/account/types.ts +++ b/src/stores/account/types.ts @@ -3,6 +3,7 @@ import type { Account as PawdirecteAccount, Session as PawdirecteSession } from import type { Session as TSSession, Authentication as TSAuthentication } from "turbawself"; import type { Client as ARDClient } from "pawrd"; import type ScolengoAPI from "scolengo-api"; +import type UphfAPI from "uphf-api"; import { SkolengoAuthConfig } from "@/services/skolengo/skolengo-types"; import { User as ScolengoAPIUser } from "scolengo-api/types/models/Common"; @@ -70,7 +71,8 @@ export enum AccountService { Turboself, ARD, Parcoursup, - Onisep + Onisep, + UPHF } /** @@ -125,6 +127,14 @@ export interface SkolengoAccount extends BaseAccount { userInfo: ScolengoAPIUser } +export interface UphfAccount extends BaseAccount { + service: AccountService.UPHF + instance?: UphfAPI.UPHF + authentication: { + refreshAuthToken: string + } +} + export interface LocalAccount extends BaseAccount { service: AccountService.Local @@ -163,6 +173,7 @@ export type PrimaryAccount = ( | PronoteAccount | EcoleDirecteAccount | SkolengoAccount + | UphfAccount | LocalAccount ); export type ExternalAccount = ( diff --git a/src/views/login/IdentityProvider/providers/UnivUphf.tsx b/src/views/login/IdentityProvider/providers/UnivUphf.tsx new file mode 100644 index 00000000..a7733a92 --- /dev/null +++ b/src/views/login/IdentityProvider/providers/UnivUphf.tsx @@ -0,0 +1,89 @@ +import React, { useState } from "react"; +import type { Screen } from "@/router/helpers/types"; + +import { authWithCredentials } from "uphf-api"; +import uuid from "@/utils/uuid-v4"; + +import { useAccounts, useCurrentAccount } from "@/stores/account"; +import { AccountService, type UphfAccount } from "@/stores/account/types"; +import defaultPersonalization from "@/services/uphf/default-personalization"; +import LoginView from "@/components/Templates/LoginView"; + +const UnivUphf_Login: Screen<"UnivUphf_Login"> = ({ navigation }) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const createStoredAccount = useAccounts(store => store.create); + const switchTo = useCurrentAccount(store => store.switchTo); + + const handleLogin = async (username: string, password: string) => { + try { + setLoading(true); + setError(null); + + const account = await authWithCredentials({ username, password }); + + const local_account: UphfAccount = { + instance: undefined, + + localID: uuid(), + service: AccountService.UPHF, + + isExternal: false, + linkedExternalLocalIDs: [], + + name: account.userData.name + " " + account.userData.firstname, + studentName: { + last: account.userData.name, + first: account.userData.firstname + }, + className: "", // TODO ? + schoolName: "Université Polytechnique Hauts-de-France", + + authentication: { + refreshAuthToken: account.userData.refreshAuthToken, + }, + personalization: await defaultPersonalization() + }; + + createStoredAccount(local_account); + setLoading(false); + switchTo(local_account); + + // We need to wait a tick to make sure the account is set before navigating. + queueMicrotask(() => { + // Reset the navigation stack to the "Home" screen. + // Prevents the user from going back to the login screen. + navigation.reset({ + index: 0, + routes: [{ name: "AccountCreated" }], + }); + }); + } + catch (error) { + if (error instanceof Error) { + setError(error.message); + } + else { + setError("Erreur inconnue"); + } + + setLoading(false); + console.error(error); + } + }; + + return ( + <> + handleLogin(username, password)} + /> + + ); +}; + +export default UnivUphf_Login;