Merge pull request #20 from linagora/1-application-basic-setup

Setting up a basic application with minimal features
This commit is contained in:
Camille Moussu
2025-07-03 17:09:02 +02:00
committed by GitHub
17 changed files with 3052 additions and 7283 deletions
+8 -8
View File
@@ -1,8 +1,8 @@
REACT_APP_SSO_BASE_URL="https://example.com" PUBLIC_SSO_BASE_URL="https://example.com"
REACT_APP_SSO_CLIENT_ID="example" PUBLIC_SSO_CLIENT_ID="example"
REACT_APP_SSO_CLIENT_SECRET="" PUBLIC_SSO_SCOPE="openid name email"
REACT_APP_SSO_SCOPE="openid name email" PUBLIC_SSO_REDIRECT_URI="https://example.com/callback"
REACT_APP_SSO_REDIRECT_URI="https://example.com/callback" PUBLIC_SSO_RESPONSE_TYPE="code"
REACT_APP_SSO_RESPONSE_TYPE="code" PUBLIC_SSO_CODE_CHALLENGE_METHOD="S256"
REACT_APP_SSO_CODE_CHALLENGE_METHOD="S256" PUBLIC_SSO_POST_LOGOUT_REDIRECT="http://example.com?logout=1"
REACT_APP_SSO_POST_LOGOUT_REDIRECT="http://example.com?logout=1" PUBLIC_CALENDAR_BASE_URL="https://calendar.example.com"
+1 -1
View File
@@ -9,7 +9,7 @@
/coverage /coverage
# production # production
/build /dist
# secrets # secrets
.DS_Store .DS_Store
+2789 -7219
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -29,10 +29,11 @@
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "PORT=5000 react-scripts start", "start": "PORT=5000 rsbuild dev",
"build": "react-scripts build", "build": "rsbuild build",
"preview": "rsbuild preview",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "lint": "eslint src"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@@ -53,15 +54,19 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@rsbuild/core": "^1.4.3",
"@rsbuild/plugin-react": "^1.3.3",
"@rsbuild/plugin-svgr": "^1.2.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2", "@types/jest": "^27.5.2",
"eslint": "^8.57.1",
"eslint-config-react-app": "^7.0.1",
"i18next-resources-for-ts": "1.4.0", "i18next-resources-for-ts": "1.4.0",
"jest": "^27.5.1", "jest": "^27.5.1",
"jest-preview": "^0.3.1", "jest-preview": "^0.3.1",
"react-scripts": "^5.0.1",
"typescript": "^4.9.5" "typescript": "^4.9.5"
} }
} }
+5 -5
View File
@@ -2,25 +2,25 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="<%= assetPrefix %>/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
content="Web site created using create-react-app" content="Web site created using create-react-app"
/> />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="<%= assetPrefix %>/logo192.png" />
<!-- <!--
manifest.json provides metadata used when your web app is installed on a manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
--> -->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="<%= assetPrefix %>/manifest.json" />
<!-- <!--
Notice the use of %PUBLIC_URL% in the tags above. Notice the use of <%= assetPrefix %> in the tags above.
It will be replaced with the URL of the `public` folder during the build. It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML. Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will Unlike "/favicon.ico" or "favicon.ico", "<%= assetPrefix %>/favicon.ico" will
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
export default defineConfig({
plugins: [pluginReact()],
html: {
template: "./public/index.html",
},
server: { port: 5000 },
});
+22 -7
View File
@@ -7,18 +7,33 @@ import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import ReactCalendar from "react-calendar"; import ReactCalendar from "react-calendar";
import "./Calendar.css"; import "./Calendar.css";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useAppSelector } from "../../app/hooks"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
import EventPopover from "../../features/Events/EventModal"; import EventPopover from "../../features/Events/EventModal";
import CalendarPopover from "../../features/Calendars/CalendarModal"; import CalendarPopover from "../../features/Calendars/CalendarModal";
import { CalendarEvent } from "../../features/Events/EventsTypes";
import {
getCalendar,
getCalendars,
} from "../../features/Calendars/CalendarApi";
import { access } from "node:fs";
import getOpenPaasUserId from "../../features/User/userAPI";
import { getCalendarsAsync } from "../../features/Calendars/CalendarSlice";
import { Calendars } from "../../features/Calendars/CalendarTypes";
import { parseCalendarEvent } from "../../features/Events/eventUtils";
export default function CalendarApp() { export default function CalendarApp() {
const calendarRef = useRef<CalendarApi | null>(null); const calendarRef = useRef<CalendarApi | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(new Date());
const tokens = useAppSelector((state) => state.user.tokens);
const calendars = useAppSelector((state) => state.calendars); const calendars = useAppSelector((state) => state.calendars);
const [selectedCalendars, setSelectedCalendars] = useState( const [selectedCalendars, setSelectedCalendars] = useState(
Object.keys(calendars).map((id) => calendars[id].name) Object.keys(calendars).map((id) => id)
); );
const events = useAppSelector((state) => state.events); const dispatch = useAppDispatch();
let filteredEvents: CalendarEvent[] = [];
selectedCalendars.forEach((id) => {
filteredEvents = filteredEvents.concat(calendars[id].events);
});
const handleCalendarToggle = (name: string) => { const handleCalendarToggle = (name: string) => {
setSelectedCalendars((prev) => setSelectedCalendars((prev) =>
@@ -103,9 +118,9 @@ export default function CalendarApp() {
<label> <label>
<input <input
type="checkbox" type="checkbox"
style={{backgroundColor:calendars[id].color}} style={{ backgroundColor: calendars[id].color }}
checked={selectedCalendars.includes(calendars[id].name)} checked={selectedCalendars.includes(id)}
onChange={() => handleCalendarToggle(calendars[id].name)} onChange={() => handleCalendarToggle(id)}
/> />
{calendars[id].name} {calendars[id].name}
</label> </label>
@@ -132,7 +147,7 @@ export default function CalendarApp() {
timeGridWeek: { titleFormat: { month: "long", year: "numeric" } }, timeGridWeek: { titleFormat: { month: "long", year: "numeric" } },
}} }}
dayMaxEvents={true} dayMaxEvents={true}
events={events} events={filteredEvents}
weekNumbers weekNumbers
weekNumberFormat={{ week: "long" }} weekNumberFormat={{ week: "long" }}
slotDuration={"00:30:00"} slotDuration={"00:30:00"}
+32 -2
View File
@@ -1,3 +1,33 @@
export default async function getCalendars() { import { cp } from "node:fs";
return {}
export async function getCalendars(userId: string, opaque_token: string) {
const response = await fetch(
`${process.env.PUBLIC_CALENDAR_BASE_URL}/dav/calendars/${userId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true`,
{
headers: {
Accept: "application/calendar+json",
Authorization: `Bearer ${opaque_token}`,
},
}
);
const calendars = await response.json();
return calendars;
}
export async function getCalendar(id: string, opaque_token: string) {
const response = await fetch(
`${process.env.PUBLIC_CALENDAR_BASE_URL}/dav/calendars/${id}.json`,
{
method: "REPORT",
headers: {
Accept: "application/json, text/plain, */*",
Authorization: `Bearer ${opaque_token}`,
},
body: JSON.stringify({
match: { start: "20250525T000000", end: "20250708T000000" },
}),
}
);
const calendar = await response.json();
return calendar;
} }
+52 -1
View File
@@ -1,6 +1,44 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Calendars } from "./CalendarTypes"; import { Calendars } from "./CalendarTypes";
import { CalendarEvent } from "../Events/EventsTypes"; import { CalendarEvent } from "../Events/EventsTypes";
import { getCalendar, getCalendars } from "./CalendarApi";
import getOpenPaasUserId from "../User/userAPI";
import { parseCalendarEvent } from "../Events/eventUtils";
export const getCalendarsAsync = createAsyncThunk<
Record<string, Calendars>, // Return type
string // Arg type (access_token)
>("calendars/getCalendars", async (access_token: string) => {
const importedCalendars: Record<string, Calendars> = {};
const user = await getOpenPaasUserId(access_token);
const calendars = await getCalendars(user.id, access_token);
const rawCalendars = calendars._embedded["dav:calendar"];
for (const cal of rawCalendars) {
const name = cal["dav:name"];
const description = cal["dav:description"];
const id = cal["calendarserver:source"]
? cal["calendarserver:source"]._links.self.href
.replace("/calendars/", "")
.replace(".json", "")
: cal._links.self.href
.replace("/calendars/", "")
.replace(".json", "")
const color = cal["apple:color"];
const calendarDetails = await getCalendar(id, access_token);
const events = calendarDetails._embedded["dav:item"].map(
(eventdata: any) => {
const datas = eventdata.data[2][0][1];
return parseCalendarEvent(datas, color);
}
);
importedCalendars[id] = { id, name, description, color, events };
}
return importedCalendars;
});
const CalendarSlice = createSlice({ const CalendarSlice = createSlice({
name: "calendars", name: "calendars",
@@ -18,6 +56,9 @@ const CalendarSlice = createSlice({
state, state,
action: PayloadAction<{ calendarUid: string; event: CalendarEvent }> action: PayloadAction<{ calendarUid: string; event: CalendarEvent }>
) => { ) => {
if (!state[action.payload.calendarUid].events) {
state[action.payload.calendarUid].events = [];
}
state[action.payload.calendarUid].events.push(action.payload.event); state[action.payload.calendarUid].events.push(action.payload.event);
}, },
removeEvent: ( removeEvent: (
@@ -33,6 +74,16 @@ const CalendarSlice = createSlice({
}); });
}, },
}, },
extraReducers: (builder) => {
builder.addCase(
getCalendarsAsync.fulfilled,
(state, action: PayloadAction<Record<string, Calendars>>) => {
Object.keys(action.payload).forEach((id) => {
state[id] = action.payload[id];
});
}
);
},
}); });
export const { addEvent, removeEvent, createCalendar } = CalendarSlice.actions; export const { addEvent, removeEvent, createCalendar } = CalendarSlice.actions;
+3 -3
View File
@@ -3,9 +3,9 @@ import { CalendarEvent } from "../Events/EventsTypes";
export interface Calendars { export interface Calendars {
id: string; id: string;
name: string; name: string;
prodid: string; prodid?: string;
color: string; color?: string;
description: string; description?: string;
calscale?: string; calscale?: string;
version?: string; version?: string;
events: CalendarEvent[]; events: CalendarEvent[];
+16 -13
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { addEvent } from "./EventsSlice"; import { addEvent } from "../Calendars/CalendarSlice";
import { CalendarEvent } from "./EventsTypes"; import { CalendarEvent } from "./EventsTypes";
import { DateSelectArg } from "@fullcalendar/core"; import { DateSelectArg } from "@fullcalendar/core";
import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
@@ -11,6 +11,7 @@ import {
Typography, Typography,
Select, Select,
MenuItem, MenuItem,
SelectChangeEvent,
} from "@mui/material"; } from "@mui/material";
function EventPopover({ function EventPopover({
@@ -22,13 +23,13 @@ function EventPopover({
anchorEl: HTMLElement | null; anchorEl: HTMLElement | null;
open: boolean; open: boolean;
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
selectedRange: any; selectedRange: DateSelectArg | null;
}) { }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const organizer = useAppSelector((state) => state.user.organiserData); const organizer = useAppSelector((state) => state.user.organiserData);
const calendars = useAppSelector((state) => state.calendars); const calendars = useAppSelector((state) => state.calendars);
const [summary, setSummary] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [location, setLocation] = useState(""); const [location, setLocation] = useState("");
const [start, setStart] = useState(""); const [start, setStart] = useState("");
@@ -44,9 +45,9 @@ function EventPopover({
const handleSave = () => { const handleSave = () => {
const newEvent: CalendarEvent = { const newEvent: CalendarEvent = {
summary, title,
start: new Date(start), start: new Date(start || ""),
end: new Date(end), end: new Date(end || ""),
uid: Date.now().toString(36), uid: Date.now().toString(36),
description, description,
location, location,
@@ -62,13 +63,13 @@ function EventPopover({
}, },
], ],
transp: "OPAQUE", transp: "OPAQUE",
color: calendars[calendarid].color,
}; };
dispatch(addEvent(newEvent)); dispatch(addEvent({ calendarUid: calendarid, event: newEvent }));
console.log(newEvent);
onClose({}, "backdropClick"); onClose({}, "backdropClick");
// Reset // Reset
setSummary(""); setTitle("");
setDescription(""); setDescription("");
setLocation(""); setLocation("");
}; };
@@ -91,16 +92,18 @@ function EventPopover({
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Create Event Create Event
</Typography> </Typography>
<Select onClick={(e) => console.log(e.target)}> <Select
onChange={(e: SelectChangeEvent) => setCalendarid(e.target.value)}
>
{Object.keys(calendars).map((calendar) => ( {Object.keys(calendars).map((calendar) => (
<MenuItem value={calendar}>{calendars[calendar].name}</MenuItem> <MenuItem value={calendar}>{calendars[calendar].name}</MenuItem>
))} ))}
</Select> </Select>
<TextField <TextField
fullWidth fullWidth
label="summary" label="title"
value={summary} value={title}
onChange={(e) => setSummary(e.target.value)} onChange={(e) => setTitle(e.target.value)}
size="small" size="small"
margin="dense" margin="dense"
/> />
+5 -3
View File
@@ -2,16 +2,18 @@ import { userAttendee, userOrganiser } from "../User/userDataTypes";
export interface CalendarEvent { export interface CalendarEvent {
uid: string; uid: string;
transp: string; transp?: string;
start: Date; // ISO date start: Date; // ISO date
end?: Date; end?: Date;
class?: string; class?: string;
x_openpass_videoconference?: unknown; x_openpass_videoconference?: unknown;
summary?: string; title?: string;
description?: string; description?: string;
location?: string; location?: string;
organizer: userOrganiser; organizer?: userOrganiser;
attendee: userAttendee[]; attendee: userAttendee[];
stamp?: Date; stamp?: Date;
sequence?: Number; sequence?: Number;
color?: string;
allday?:Boolean
} }
+71
View File
@@ -0,0 +1,71 @@
import { userAttendee } from "../User/userDataTypes";
import { CalendarEvent } from "./EventsTypes";
type RawEntry = [string, Record<string, string>, string, any];
export function parseCalendarEvent(
data: RawEntry[],
color: string
): CalendarEvent {
const event: Partial<CalendarEvent> = { color, attendee: [] };
for (const [key, params, type, value] of data) {
switch (key.toLowerCase()) {
case "uid":
event.uid = value;
break;
case "transp":
event.transp = value;
break;
case "dtstart":
event.start = value;
break;
case "dtend":
event.end = value;
break;
case "class":
event.class = value;
break;
case "x-openpaas-videoconference":
event.x_openpass_videoconference = value;
break;
case "summary":
event.title = value;
break;
case "description":
event.description = value;
break;
case "location":
event.location = value;
break;
case "organizer":
event.organizer = {
cn: params?.cn || "",
cal_address: value.replace(/^mailto:/, ""),
};
break;
case "attendee":
(event.attendee as userAttendee[]).push({
cn: params?.cn || "",
cal_address: value.replace(/^mailto:/, ""),
partstat: params?.partstat || "",
rsvp: params?.rsvp || "",
role: params?.role || "",
cutype: params?.cutype || "",
});
break;
case "dtstamp":
event.stamp = new Date(value);
break;
case "sequence":
event.sequence = Number(value);
break;
}
}
if (!event.uid || !event.start) {
throw new Error(`Missing required event fields`);
}
return event as CalendarEvent;
}
+4 -2
View File
@@ -2,8 +2,9 @@ import { useEffect, useRef } from "react";
import { Callback } from "./oidcAuth"; import { Callback } from "./oidcAuth";
import { useAppDispatch } from "../../app/hooks"; import { useAppDispatch } from "../../app/hooks";
import { push } from "redux-first-history"; import { push } from "redux-first-history";
import { setUserData } from "./userSlice"; import { setTokens, setUserData } from "./userSlice";
import { Loading } from "../../components/Loading/Loading"; import { Loading } from "../../components/Loading/Loading";
import { getCalendarsAsync } from "../Calendars/CalendarSlice";
export function CallbackResume() { export function CallbackResume() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -19,8 +20,9 @@ export function CallbackResume() {
const runCallback = async () => { const runCallback = async () => {
try { try {
const data = await Callback(saved?.code_verifier, saved?.state); const data = await Callback(saved?.code_verifier, saved?.state);
console.log("data:", data);
dispatch(setUserData(data?.userinfo)); dispatch(setUserData(data?.userinfo));
dispatch(setTokens(data?.tokenSet));
dispatch(getCalendarsAsync(data?.tokenSet.access_token || ""));
sessionStorage.removeItem("redirectState"); sessionStorage.removeItem("redirectState");
// Redirect to main page after successful callback // Redirect to main page after successful callback
dispatch(push("/")); dispatch(push("/"));
+8 -14
View File
@@ -1,23 +1,19 @@
import * as client from "openid-client"; import * as client from "openid-client";
export const clientConfig = { export const clientConfig = {
url: process.env.REACT_APP_SSO_BASE_URL || "", url: process.env.PUBLIC_SSO_BASE_URL || "",
client_id: process.env.REACT_APP_SSO_CLIENT_ID || "", client_id: process.env.PUBLIC_SSO_CLIENT_ID || "",
client_secret: process.env.REACT_APP_SSO_CLIENT_SECRET || "", scope: process.env.PUBLIC_SSO_SCOPE || "",
scope: process.env.REACT_APP_SSO_SCOPE || "", redirect_uri: process.env.PUBLIC_SSO_REDIRECT_URI || "",
redirect_uri: process.env.REACT_APP_SSO_REDIRECT_URI || "", response_type: process.env.PUBLIC_SSO_RESPONSE_TYPE || "",
response_type: process.env.REACT_APP_SSO_RESPONSE_TYPE || "", code_challenge_method: process.env.PUBLIC_SSO_CODE_CHALLENGE_METHOD || "",
code_challenge_method: process.env.REACT_APP_SSO_CODE_CHALLENGE_METHOD || "", post_logout_redirect_uri: process.env.PUBLIC_SSO_POST_LOGOUT_REDIRECT || "",
post_logout_redirect_uri:
process.env.REACT_APP_SSO_POST_LOGOUT_REDIRECT || "",
}; };
export async function getClientConfig() { export async function getClientConfig() {
console.log(process.env.REACT_APP_SSO_BASE_URL)
return await client.discovery( return await client.discovery(
new URL(clientConfig.url), new URL(clientConfig.url),
clientConfig.client_id, clientConfig.client_id
clientConfig.client_secret
); );
} }
@@ -37,7 +33,6 @@ export async function Auth() {
parameters.state = state; parameters.state = state;
} }
let redirectTo = client.buildAuthorizationUrl(openIdClientConfig, parameters); let redirectTo = client.buildAuthorizationUrl(openIdClientConfig, parameters);
console.log(redirectTo);
return { redirectTo, code_verifier, state }; return { redirectTo, code_verifier, state };
} }
@@ -58,7 +53,6 @@ export async function Callback(code_verifier: string, state: any) {
console.log("Callback URL:", currentUrl.toString()); console.log("Callback URL:", currentUrl.toString());
console.log("Code verifier:", code_verifier); console.log("Code verifier:", code_verifier);
console.log("State:", state);
const tokenSet = await client.authorizationCodeGrant( const tokenSet = await client.authorizationCodeGrant(
openIdClientConfig, openIdClientConfig,
+12
View File
@@ -0,0 +1,12 @@
export default async function getOpenPaasUserId(opaque_token: string) {
const response = await fetch(
`${process.env.PUBLIC_CALENDAR_BASE_URL}/api/user`,
{
headers: {
Authorization: `Bearer ${opaque_token}`,
},
}
);
const user = await response.json();
return user;
}
+5 -1
View File
@@ -6,6 +6,7 @@ export const userSlice = createSlice({
initialState: { initialState: {
userData: null as unknown as userData, userData: null as unknown as userData,
organiserData: null as unknown as userOrganiser, organiserData: null as unknown as userOrganiser,
tokens: null as unknown as Record<string, string>,
}, },
reducers: { reducers: {
setUserData: (state, action) => { setUserData: (state, action) => {
@@ -16,10 +17,13 @@ export const userSlice = createSlice({
state.organiserData.cn = action.payload.sub; state.organiserData.cn = action.payload.sub;
state.organiserData.cal_address = `mailto:${action.payload.email}`; state.organiserData.cal_address = `mailto:${action.payload.email}`;
}, },
setTokens: (state, action) => {
state.tokens = action.payload;
},
}, },
}); });
// Action creators are generated for each case reducer function // Action creators are generated for each case reducer function
export const { setUserData } = userSlice.actions; export const { setUserData, setTokens } = userSlice.actions;
export default userSlice.reducer; export default userSlice.reducer;