mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee050ab37 | |||
| f6ec01f3c8 | |||
| 3f77a9d884 | |||
| c6de0041da | |||
| 0c98dee3ef | |||
| 768b983f8d | |||
| 3a63b1e410 | |||
| 65b194d432 | |||
| d76b4c9b9f | |||
| 09c7edecb8 | |||
| f625df6508 | |||
| ac87980a27 | |||
| 7cab46dc29 |
@@ -47,6 +47,7 @@ and this project adheres to
|
||||
### Changed
|
||||
|
||||
- ✨(frontend) add clickable settings general link in idle modal #974
|
||||
- ♻️(backend) refactor external API token-related items #1006
|
||||
|
||||
## [1.6.0] - 2026-02-10
|
||||
|
||||
|
||||
@@ -38,6 +38,20 @@ COPY ./docker/dinum-frontend/assets/ \
|
||||
COPY ./docker/dinum-frontend/fonts/ \
|
||||
./dist/assets/fonts/
|
||||
|
||||
# ---- Outlook add-in builder image ----
|
||||
FROM node:20-alpine AS outlook-addin-builder
|
||||
|
||||
WORKDIR /home/outlook-addin
|
||||
|
||||
COPY ./src/addins/outlook-addin/package.json ./package.json
|
||||
COPY ./src/addins/outlook-addin/package-lock.json ./package-lock.json
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY ./src/addins/outlook-addin/ .
|
||||
|
||||
RUN npx webpack --mode production
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||
|
||||
@@ -59,6 +73,10 @@ COPY --from=meet-builder \
|
||||
/home/frontend/dist \
|
||||
/usr/share/nginx/html
|
||||
|
||||
COPY --from=outlook-addin-builder \
|
||||
/home/outlook-addin/dist \
|
||||
/usr/share/nginx/html/outlook-addin
|
||||
|
||||
COPY ./src/frontend/default.conf /etc/nginx/conf.d
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies", "noChangeLog"],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"schedule": ["on the first day of the month"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"minimumReleaseAge": "7 days",
|
||||
"internalChecksFilter": "strict"
|
||||
},
|
||||
{
|
||||
"groupName": "python dependencies",
|
||||
"matchManagers": ["setup-cfg", "pep621"],
|
||||
"schedule": ["on the first day of the month"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"minimumReleaseAge": "7 days"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored python dependencies",
|
||||
@@ -15,6 +30,12 @@
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed django versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["django"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"plugins": [
|
||||
"office-addins"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:office-addins/recommended"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"esmodules": false
|
||||
}
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
||||
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
||||
<Version>0.0.1.0</Version>
|
||||
<ProviderName>Visio</ProviderName>
|
||||
<DefaultLocale>en-US</DefaultLocale>
|
||||
<DisplayName DefaultValue="Visio"/>
|
||||
<Description DefaultValue="Ajoutez facilement un lien de réunion Visio à vos emails et événements Outlook."/>
|
||||
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
|
||||
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
|
||||
<SupportUrl DefaultValue="https://www.contoso.com/help"/>
|
||||
<AppDomains>
|
||||
<AppDomain>https://localhost:3000</AppDomain>
|
||||
<AppDomain>https://meet.127.0.0.1.nip.io</AppDomain>
|
||||
</AppDomains>
|
||||
<Hosts>
|
||||
<Host Name="Mailbox"/>
|
||||
</Hosts>
|
||||
<Requirements>
|
||||
<Sets>
|
||||
<Set Name="Mailbox" MinVersion="1.1"/>
|
||||
</Sets>
|
||||
</Requirements>
|
||||
<FormSettings>
|
||||
<Form xsi:type="ItemRead">
|
||||
<DesktopSettings>
|
||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
<RequestedHeight>250</RequestedHeight>
|
||||
</DesktopSettings>
|
||||
</Form>
|
||||
<Form xsi:type="ItemEdit">
|
||||
<DesktopSettings>
|
||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
</DesktopSettings>
|
||||
</Form>
|
||||
</FormSettings>
|
||||
<Permissions>ReadWriteItem</Permissions>
|
||||
<Rule xsi:type="RuleCollection" Mode="Or">
|
||||
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
|
||||
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
|
||||
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
|
||||
</Rule>
|
||||
<DisableEntityHighlighting>false</DisableEntityHighlighting>
|
||||
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
|
||||
<Requirements>
|
||||
<bt:Sets DefaultMinVersion="1.3">
|
||||
<bt:Set Name="Mailbox"/>
|
||||
</bt:Sets>
|
||||
</Requirements>
|
||||
<Hosts>
|
||||
<Host xsi:type="MailHost">
|
||||
<DesktopFormFactor>
|
||||
<FunctionFile resid="Commands.Url"/>
|
||||
|
||||
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
|
||||
<ExtensionPoint xsi:type="MessageReadCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="msgReadGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="msgReadOpenPaneButton">
|
||||
<Label resid="TaskpaneButton.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="TaskpaneButton.Label"/>
|
||||
<Description resid="TaskpaneButton.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Icon.16x16"/>
|
||||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
|
||||
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="msgComposeGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
|
||||
<Label resid="TaskpaneButton.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="TaskpaneButton.Label"/>
|
||||
<Description resid="TaskpaneButton.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Icon.16x16"/>
|
||||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
|
||||
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="apptComposeGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
|
||||
<!-- Button 1: Generate meeting link (function call) -->
|
||||
<Control xsi:type="Button" id="apptGenerateLinkButton">
|
||||
<Label resid="GenerateLink.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="GenerateLink.Label"/>
|
||||
<Description resid="GenerateLink.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Icon.16x16"/>
|
||||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ExecuteFunction">
|
||||
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
||||
</Action>
|
||||
</Control>
|
||||
|
||||
<!-- Button 2: Open settings taskpane -->
|
||||
<Control xsi:type="Button" id="apptOpenSettingsButton">
|
||||
<Label resid="OpenSettings.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="OpenSettings.Label"/>
|
||||
<Description resid="OpenSettings.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Icon.16x16"/>
|
||||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
</DesktopFormFactor>
|
||||
</Host>
|
||||
</Hosts>
|
||||
<Resources>
|
||||
<bt:Images>
|
||||
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
|
||||
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
|
||||
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
|
||||
</bt:Images>
|
||||
<bt:Urls>
|
||||
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
|
||||
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
</bt:Urls>
|
||||
<bt:ShortStrings>
|
||||
<bt:String id="GroupLabel" DefaultValue="Visio"/>
|
||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir le panneau"/>
|
||||
<bt:String id="GenerateLink.Label" DefaultValue="Générer un lien de réunion"/>
|
||||
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
|
||||
</bt:ShortStrings>
|
||||
<bt:LongStrings>
|
||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre le panneau de connexion Visio."/>
|
||||
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion Visio et l'insère dans l'événement."/>
|
||||
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion Visio."/>
|
||||
</bt:LongStrings>
|
||||
</Resources>
|
||||
</VersionOverrides>
|
||||
</OfficeApp>
|
||||
+16211
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "office-addin-taskpane-js",
|
||||
"version": "0.0.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/OfficeDev/Office-Addin-TaskPane-JS.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"config": {
|
||||
"app_to_debug": "outlook",
|
||||
"app_type_to_debug": "desktop",
|
||||
"dev_server_port": 3000
|
||||
},
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"build:dev": "webpack --mode development",
|
||||
"dev-server": "webpack serve --mode development",
|
||||
"lint": "office-addin-lint check",
|
||||
"lint:fix": "office-addin-lint fix",
|
||||
"prettier": "office-addin-lint prettier",
|
||||
"signin": "office-addin-dev-settings m365-account login",
|
||||
"signout": "office-addin-dev-settings m365-account logout",
|
||||
"start": "office-addin-debugging start manifest.xml",
|
||||
"stop": "office-addin-debugging stop manifest.xml",
|
||||
"validate": "office-addin-manifest validate manifest.xml",
|
||||
"watch": "webpack --mode development --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.36.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@types/office-js": "^1.0.377",
|
||||
"@types/office-runtime": "^1.0.35",
|
||||
"acorn": "^8.11.3",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"eslint-plugin-office-addins": "^4.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^5.0.0",
|
||||
"html-webpack-inject-attributes-plugin": "^1.0.6",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"office-addin-cli": "^2.0.3",
|
||||
"office-addin-debugging": "^6.0.3",
|
||||
"office-addin-dev-certs": "^2.0.3",
|
||||
"office-addin-lint": "^3.0.3",
|
||||
"office-addin-manifest": "^2.0.3",
|
||||
"office-addin-prettier-config": "^2.0.1",
|
||||
"os-browserify": "^0.3.0",
|
||||
"process": "^0.11.10",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "5.1.0"
|
||||
},
|
||||
"prettier": "office-addin-prettier-config",
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,80 @@
|
||||
/* global Office */
|
||||
const { loadSession, buildMeetingMessage, BASE_URL } = require("../common");
|
||||
|
||||
Office.onReady(() => {});
|
||||
|
||||
function generateMeetingLinkFromCalendar(event) {
|
||||
const session = loadSession();
|
||||
|
||||
if (!session?.access_token) {
|
||||
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
|
||||
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
||||
message: "Vous n'êtes pas connecté. Ouvrez les paramètres pour vous connecter.",
|
||||
});
|
||||
event.completed();
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + session.access_token,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
console.log("Room created:", data);
|
||||
|
||||
const { url, message } = buildMeetingMessage(data);
|
||||
const item = Office.context.mailbox.item;
|
||||
|
||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
item.notificationMessages.replaceAsync("meetNotif", {
|
||||
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
||||
message: `Erreur de lecture: ${getResult.error.message}`,
|
||||
});
|
||||
event.completed();
|
||||
return;
|
||||
}
|
||||
|
||||
item.body.setAsync(getResult.value + message, { coercionType: Office.CoercionType.Html }, (setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
item.notificationMessages.replaceAsync("meetNotif", {
|
||||
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
||||
message: `Erreur d'insertion: ${setResult.error.message}`,
|
||||
});
|
||||
event.completed();
|
||||
return;
|
||||
}
|
||||
|
||||
item.location.setAsync(url, (locationResult) => {
|
||||
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
|
||||
item.notificationMessages.replaceAsync("meetNotif", {
|
||||
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
|
||||
message: "Lien de réunion inséré !",
|
||||
icon: "Icon.80x80",
|
||||
persistent: false,
|
||||
});
|
||||
} else {
|
||||
item.notificationMessages.replaceAsync("meetNotif", {
|
||||
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
||||
message: `Erreur de localisation: ${locationResult.error.message}`,
|
||||
});
|
||||
}
|
||||
event.completed();
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
|
||||
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
||||
message: `Erreur: ${err.message}`,
|
||||
});
|
||||
event.completed();
|
||||
});
|
||||
}
|
||||
|
||||
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLinkFromCalendar);
|
||||
@@ -0,0 +1,99 @@
|
||||
/* global Office */
|
||||
|
||||
const BASE_URL = "https://meet.127.0.0.1.nip.io"; // todo - use env variable
|
||||
|
||||
// ─── Session Storage ──────────────────────────────────────────────────────
|
||||
|
||||
function saveSession(data) {
|
||||
const expiresAt = data.expires_in
|
||||
? new Date(Date.now() + data.expires_in * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
const payload = JSON.stringify({
|
||||
...data,
|
||||
expiresAt,
|
||||
savedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
localStorage.setItem("meetSession", payload);
|
||||
|
||||
const rs = Office.context.roamingSettings;
|
||||
rs.set("meetSession", payload);
|
||||
rs.saveAsync((result) => {
|
||||
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
console.error("RoamingSettings save failed:", result.error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
let session = null;
|
||||
|
||||
try {
|
||||
const stored = Office.context.roamingSettings.get("meetSession");
|
||||
if (stored) session = JSON.parse(stored);
|
||||
} catch (e) {
|
||||
console.warn("RoamingSettings read failed:", e);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
try {
|
||||
const stored = localStorage.getItem("meetSession");
|
||||
if (stored) session = JSON.parse(stored);
|
||||
} catch (e) {
|
||||
console.warn("localStorage read failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
if (session.expiresAt && new Date() > new Date(session.expiresAt)) {
|
||||
console.warn("Token expired, clearing session.");
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
localStorage.removeItem("meetSession");
|
||||
try {
|
||||
const rs = Office.context.roamingSettings;
|
||||
rs.remove("meetSession");
|
||||
rs.saveAsync(() => console.log("RoamingSettings cleared."));
|
||||
} catch (e) {
|
||||
console.warn("Could not clear RoamingSettings:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Meeting Message Builder ───────────────────────────────────────────────
|
||||
|
||||
function buildMeetingMessage(data) {
|
||||
const url = data.url;
|
||||
const phone = data.telephony?.phone_number;
|
||||
const pin = data.telephony?.pin_code;
|
||||
|
||||
const formattedPin = pin
|
||||
? pin.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#"
|
||||
: "";
|
||||
|
||||
const formattedPhone = phone
|
||||
? phone.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5")
|
||||
: phone;
|
||||
|
||||
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
|
||||
────────────────────────────────────────
|
||||
Rejoindre la réunion LaSuite Meet
|
||||
|
||||
<a href="${url}">${url}</a>
|
||||
|
||||
Ou appelez (audio uniquement)
|
||||
(FR) ${formattedPhone}
|
||||
Code : ${formattedPin}
|
||||
────────────────────────────────────────</pre>`;
|
||||
|
||||
return { url, message };
|
||||
}
|
||||
|
||||
module.exports = { BASE_URL, saveSession, loadSession, clearSession, buildMeetingMessage };
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Visio</title>
|
||||
<link rel="stylesheet" href="taskpane.css" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
||||
|
||||
<div id="app-body">
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="view-loading">
|
||||
<p class="intro-text">Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Unauthenticated -->
|
||||
<div id="view-unauth" style="display:none;">
|
||||
<p class="intro-text">
|
||||
<span>Ajoutez facilement un lien de réunion Visio à vos événements Outlook.</span>
|
||||
<a href="https://meet.numerique.gouv.fr" target="_blank" class="learn-more">En savoir plus</a>
|
||||
</p>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<button class="proconnect-button" id="btn-connect">
|
||||
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
|
||||
</button>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://www.proconnect.gouv.fr/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Qu’est-ce que ProConnect ? - nouvelle fenêtre"
|
||||
>
|
||||
Qu’est-ce que ProConnect ?
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated -->
|
||||
<div id="view-auth" style="display:none;">
|
||||
<div id="btn-container">
|
||||
<button id="btn-generate">Ajouter une réunion Visio</button>
|
||||
<button id="btn-disconnect">Se déconnecter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="status"></p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,223 @@
|
||||
|
||||
const { BASE_URL, loadSession, saveSession, clearSession, buildMeetingMessage } = require("../common");
|
||||
|
||||
// ─── Views ────────────────────────────────────────────────────────────────
|
||||
|
||||
function showView(name) {
|
||||
document.getElementById("view-loading").style.display = "none";
|
||||
document.getElementById("view-unauth").style.display = "none";
|
||||
document.getElementById("view-auth").style.display = "none";
|
||||
document.getElementById(`view-${name}`).style.display = "block";
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
document.getElementById("status").textContent = msg;
|
||||
}
|
||||
|
||||
// ─── Polling ──────────────────────────────────────────────────────────────
|
||||
|
||||
function startPolling(session_id, { onSuccess, onTimeout, onError }) {
|
||||
let pollCount = 0;
|
||||
const pollInterval = setInterval(() => {
|
||||
// ─── Timeout after 3 minutes ──────────────────────────────
|
||||
if (pollCount++ > 180) {
|
||||
clearInterval(pollInterval);
|
||||
onTimeout?.();
|
||||
return;
|
||||
}
|
||||
fetch(`${BASE_URL}/api/v1.0/addons/sessions/wip/`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((sessionData) => {
|
||||
console.log("Polling:", sessionData);
|
||||
if (sessionData.state === "authenticated" && sessionData.access_token) {
|
||||
clearInterval(pollInterval);
|
||||
onSuccess?.(sessionData);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
clearInterval(pollInterval);
|
||||
onError?.(err);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return pollInterval;
|
||||
}
|
||||
|
||||
// ─── Transit Dialog ───────────────────────────────────────────────────────
|
||||
|
||||
function openTransitDialog(transit_token, { onCancel, onError }) {
|
||||
const meetUrl = `${BASE_URL}/addons/transit/?transit_token=${transit_token}`;
|
||||
|
||||
Office.context.ui.displayDialogAsync(
|
||||
meetUrl,
|
||||
{ height: 60, width: 50, displayInIframe: false },
|
||||
(asyncResult) => {
|
||||
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
|
||||
onError?.(asyncResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = asyncResult.value;
|
||||
|
||||
dialog.addEventHandler(Office.EventType.DialogMessageReceived, () => {
|
||||
onCancel?.();
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
dialog.addEventHandler(Office.EventType.DialogEventReceived, (arg) => {
|
||||
if (arg.error === 12006) {
|
||||
setStatus("Dialog fermé. En attente d'authentification...");
|
||||
}
|
||||
});
|
||||
|
||||
return dialog;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Auth Flow ────────────────────────────────────────────────────────────
|
||||
|
||||
function connect() {
|
||||
setStatus("Démarrage de la session...");
|
||||
|
||||
fetch(`${BASE_URL}/api/v1.0/addons/sessions/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const session_id = data.session_id;
|
||||
const transit_token = data.transit_token;
|
||||
setStatus("En attente d'authentification...");
|
||||
|
||||
const pollInterval = startPolling(session_id, {
|
||||
onSuccess: (sessionData) => {
|
||||
saveSession(sessionData);
|
||||
setStatus("Connecté !");
|
||||
showView("auth");
|
||||
},
|
||||
onTimeout: () => {
|
||||
setStatus("Délai d'authentification dépassé. Veuillez réessayer.");
|
||||
showView("unauth");
|
||||
},
|
||||
onError: (err) => {
|
||||
setStatus(`Erreur de polling: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
openTransitDialog(transit_token, {
|
||||
onCancel: () => clearInterval(pollInterval),
|
||||
onError: (err) => {
|
||||
clearInterval(pollInterval);
|
||||
setStatus(`Erreur dialog: ${err.message}`);
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setStatus(`Erreur de connexion: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
clearSession();
|
||||
setStatus("Déconnecté.");
|
||||
showView("unauth");
|
||||
}
|
||||
|
||||
function generateMeetingLink() {
|
||||
const session = loadSession();
|
||||
if (!session?.access_token) {
|
||||
setStatus("Session introuvable. Veuillez vous reconnecter.");
|
||||
showView("unauth");
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById("btn-generate");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Génération...";
|
||||
|
||||
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + session.access_token,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
console.log("Room created:", data);
|
||||
|
||||
const { url, message } = buildMeetingMessage(data);
|
||||
const item = Office.context.mailbox.item;
|
||||
|
||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
setStatus(`Erreur de lecture: ${getResult.error.message}`);
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Ajouter une réunion Visio";
|
||||
return;
|
||||
}
|
||||
|
||||
item.body.setAsync(
|
||||
getResult.value + message,
|
||||
{ coercionType: Office.CoercionType.Html },
|
||||
(setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
setStatus(`Erreur d'insertion: ${setResult.error.message}`);
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Ajouter une réunion Visio";
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── If calendar event, also set location ──────────────
|
||||
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
|
||||
item.location.setAsync(url, (locationResult) => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Ajouter une réunion Visio";
|
||||
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
|
||||
setStatus("Lien de réunion inséré !");
|
||||
} else {
|
||||
setStatus(`Erreur de localisation: ${locationResult.error.message}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Ajouter une réunion Visio";
|
||||
setStatus("Lien de réunion inséré !");
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Ajouter une réunion Visio";
|
||||
setStatus(`Erreur: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Init ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Office.onReady((info) => {
|
||||
if (info.host === Office.HostType.Outlook) {
|
||||
document.getElementById("sideload-msg").style.display = "none";
|
||||
document.getElementById("app-body").style.display = "flex";
|
||||
|
||||
document.getElementById("btn-connect").onclick = connect;
|
||||
document.getElementById("btn-disconnect").onclick = disconnect;
|
||||
document.getElementById("btn-generate").onclick = generateMeetingLink;
|
||||
|
||||
const session = loadSession();
|
||||
if (session?.state === "authenticated" && session?.access_token) {
|
||||
setStatus("Connecté.");
|
||||
showView("auth");
|
||||
} else {
|
||||
showView("unauth");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
const devCerts = require("office-addin-dev-certs");
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
|
||||
|
||||
const urlDev = "https://localhost:3000/";
|
||||
const urlProd = "https://meet.127.0.0.1.nip.io/outlook-addin/";
|
||||
|
||||
async function getHttpsOptions() {
|
||||
const httpsOptions = await devCerts.getHttpsServerOptions();
|
||||
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
|
||||
}
|
||||
|
||||
module.exports = async (env, options) => {
|
||||
const dev = options.mode === "development";
|
||||
const config = {
|
||||
devtool: "source-map",
|
||||
entry: {
|
||||
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
|
||||
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
|
||||
commands: "./src/commands/commands.js",
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".html", ".js"],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "babel-loader",
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.html$/,
|
||||
exclude: /node_modules/,
|
||||
use: "html-loader",
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|gif|ico)$/,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "assets/[name][ext][query]",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "taskpane.html",
|
||||
template: "./src/taskpane/taskpane.html",
|
||||
chunks: ["polyfill", "taskpane"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
nonce: "NONCE_PLACEHOLDER",
|
||||
},
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: "assets/*",
|
||||
to: "assets/[name][ext][query]",
|
||||
},
|
||||
{
|
||||
from: "manifest*.xml",
|
||||
to: "[name]" + "[ext]",
|
||||
transform(content) {
|
||||
if (dev) {
|
||||
return content;
|
||||
} else {
|
||||
return content.toString().replace(new RegExp(urlDev, "g"), urlProd);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "commands.html",
|
||||
template: "./src/commands/commands.html",
|
||||
chunks: ["polyfill", "commands"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
nonce: "NONCE_PLACEHOLDER",
|
||||
},
|
||||
}),
|
||||
new htmlWebpackInjectAttributesPlugin(),
|
||||
],
|
||||
devServer: {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
server: {
|
||||
type: "https",
|
||||
options:
|
||||
env.WEBPACK_BUILD || options.https !== undefined
|
||||
? options.https
|
||||
: await getHttpsOptions(),
|
||||
},
|
||||
port: process.env.npm_package_config_dev_server_port || 3000,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
"""Meet core add-ons module."""
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Authentication session management for add-ons using temporary cache-based sessions."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
from core.models import User
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class SessionOwnershipError(Exception):
|
||||
"""Raised when the claimed session_id does not match the result_token binding."""
|
||||
|
||||
class SessionState(str, Enum):
|
||||
"""Add-on authentication session states."""
|
||||
|
||||
PENDING = "pending"
|
||||
AUTHENTICATED = "authenticated"
|
||||
|
||||
|
||||
class TokenExchangeService:
|
||||
"""Manage temporary authentication sessions for add-on JWT token exchange."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the service with the configured token service."""
|
||||
|
||||
self._token_service = JwtTokenService(
|
||||
secret_key=settings.ADDONS_JWT_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_JWT_ALG,
|
||||
issuer=settings.ADDONS_JWT_ISSUER,
|
||||
audience=settings.ADDONS_JWT_AUDIENCE, # todo - precise
|
||||
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
|
||||
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
def _session_cache_key(self, session_id: str) -> str:
|
||||
"""Generate cache key for a session ID."""
|
||||
return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}"
|
||||
|
||||
def _token_cache_key(self, result_token: str) -> str:
|
||||
"""Wip."""
|
||||
return f"{settings.ADDONS_SESSION_TOKEN_PREFIX}_{result_token}"
|
||||
|
||||
def init_session(self) -> tuple[str, str, str]:
|
||||
"""Create a new pending authentication session and return its ID."""
|
||||
|
||||
session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH)
|
||||
result_token = secrets.token_urlsafe(32) # separate, never in any UR
|
||||
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
seconds=settings.ADDONS_SESSION_TIMEOUT
|
||||
)
|
||||
|
||||
session_data = {
|
||||
"state": SessionState.PENDING,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
}
|
||||
|
||||
# Store the session itself
|
||||
cache.set(
|
||||
self._session_cache_key(session_id),
|
||||
session_data,
|
||||
timeout=settings.ADDONS_SESSION_TIMEOUT,
|
||||
)
|
||||
|
||||
# Store the token → session_id binding (same TTL)
|
||||
cache.set(
|
||||
self._token_cache_key(result_token),
|
||||
session_id,
|
||||
timeout=settings.ADDONS_SESSION_TIMEOUT,
|
||||
)
|
||||
|
||||
# Transit token → session_id, very short TTL, one-time use
|
||||
transit_token = secrets.token_urlsafe(32)
|
||||
cache.set(
|
||||
f"addon_transit_{transit_token}",
|
||||
session_id,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
print('$$ init transit_token')
|
||||
print(transit_token)
|
||||
|
||||
return session_id, result_token, transit_token
|
||||
|
||||
# todo - wip
|
||||
def get_session(self, session_id: str) -> dict:
|
||||
"""Retrieve session data and clear it if authenticated."""
|
||||
|
||||
return self._get_and_maybe_clear(session_id)
|
||||
|
||||
def get_session_by_token(self, result_token: str, claimed_session_id: str) -> dict:
|
||||
"""Resolve result_token → session_id → session data.
|
||||
|
||||
Verifies that the claimed_session_id matches the token binding,
|
||||
proving the caller initiated this session (ownership check).
|
||||
Clears the session once authenticated (one-time read).
|
||||
"""
|
||||
session_id = cache.get(self._token_cache_key(result_token))
|
||||
if not session_id:
|
||||
return {}
|
||||
|
||||
print("$$$ session_id")
|
||||
print(session_id)
|
||||
|
||||
print("$$$ claimed_session_id")
|
||||
print(claimed_session_id)
|
||||
|
||||
if not secrets.compare_digest(session_id, claimed_session_id):
|
||||
raise SessionOwnershipError("Session ID does not match token binding.")
|
||||
|
||||
return self._get_and_maybe_clear(session_id)
|
||||
|
||||
def _get_and_maybe_clear(self, session_id: str) -> dict:
|
||||
"""Wip."""
|
||||
|
||||
cache_key = self._session_cache_key(session_id)
|
||||
data = cache.get(cache_key)
|
||||
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
if data.get("state") == SessionState.AUTHENTICATED:
|
||||
# One-time read: clear both the session and the token binding
|
||||
self.clear_session(session_id)
|
||||
|
||||
# Return copy without internal fields
|
||||
internal_fields = {"expires_at"}
|
||||
return {k: v for k, v in data.items() if k not in internal_fields}
|
||||
|
||||
def clear_session(self, session_id: str, result_token: str | None = None) -> None:
|
||||
"""Wip."""
|
||||
cache.delete(self._session_cache_key(session_id))
|
||||
if result_token:
|
||||
cache.delete(self._token_cache_key(result_token))
|
||||
|
||||
def set_access_token(self, user: User, session_id: str):
|
||||
"""Generate and store access token for an authenticated user session."""
|
||||
|
||||
cache_key = self._session_cache_key(session_id)
|
||||
existing_data = cache.get(cache_key)
|
||||
|
||||
if not existing_data:
|
||||
raise SuspiciousOperation("Session not found.")
|
||||
|
||||
expires_at = existing_data.get("expires_at", None)
|
||||
|
||||
if not expires_at:
|
||||
self.clear_session(session_id)
|
||||
raise SuspiciousOperation("Invalid session data.")
|
||||
|
||||
remaining_seconds = int(
|
||||
(
|
||||
datetime.fromisoformat(expires_at) - datetime.now(timezone.utc)
|
||||
).total_seconds()
|
||||
)
|
||||
|
||||
if remaining_seconds <= 0:
|
||||
self.clear_session(session_id)
|
||||
raise SuspiciousOperation("Session expired.")
|
||||
|
||||
if existing_data.get("state") != SessionState.PENDING:
|
||||
self.clear_session(session_id)
|
||||
raise SuspiciousOperation("Access token already set.")
|
||||
|
||||
response = self._token_service.generate_jwt(user, settings.ADDONS_SCOPES)
|
||||
new_data = {
|
||||
**existing_data,
|
||||
**response,
|
||||
"state": SessionState.AUTHENTICATED,
|
||||
}
|
||||
|
||||
cache.set(cache_key, new_data, timeout=remaining_seconds)
|
||||
|
||||
def token_to_session(self, result_token):
|
||||
"""wip."""
|
||||
return None
|
||||
|
||||
def consume_transit_token(self, transit_token: str) -> str | None:
|
||||
"""Resolve and immediately delete the transit token (one-time use)."""
|
||||
key = f"addon_transit_{transit_token}"
|
||||
session_id = cache.get(key)
|
||||
if session_id:
|
||||
cache.delete(key) # consumed — cannot be replayed
|
||||
return session_id
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Add-ons views."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from core.addons.service import SessionState, TokenExchangeService
|
||||
|
||||
|
||||
def render_error(request, message, status=400):
|
||||
"""Render simple error page."""
|
||||
return render(request, "addons/error.html", {"message": message}, status=status)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def transit_page(request):
|
||||
"""Initialize authentication flow for add-on session."""
|
||||
|
||||
transit_token = request.GET.get("transit_token")
|
||||
|
||||
if not transit_token:
|
||||
return render_error(request, _("Transit token is required."), status=400)
|
||||
|
||||
session_id = TokenExchangeService().consume_transit_token(transit_token)
|
||||
|
||||
if not session_id:
|
||||
return render_error(request, _("Invalid or expired transit token."), status=404)
|
||||
|
||||
# Validate the session is still pending
|
||||
data = TokenExchangeService().get_session(session_id)
|
||||
if not data:
|
||||
return render_error(request, _("Session not found or expired."), status=404)
|
||||
|
||||
if data.get("state") != SessionState.PENDING:
|
||||
return render_error(request, _("Invalid session state."), status=400)
|
||||
|
||||
request.session[settings.ADDONS_SESSION_KEY_AUTH] = session_id
|
||||
|
||||
return_to = f"{settings.APPLICATION_BASE_URL}/addons/redirect"
|
||||
return redirect(f"/api/{settings.API_VERSION}/authenticate/?returnTo={return_to}")
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def redirect_page(request):
|
||||
"""Complete authentication and close the popup window."""
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return render_error(request, _("Authentication required."), status=401)
|
||||
|
||||
session_id = request.session.pop(settings.ADDONS_SESSION_KEY_AUTH, None)
|
||||
|
||||
if not session_id:
|
||||
return render_error(request, _("No active session found."), status=404)
|
||||
|
||||
try:
|
||||
TokenExchangeService().set_access_token(request.user, session_id)
|
||||
except SuspiciousOperation:
|
||||
return render_error(request, _("Invalid or expired session."), status=400)
|
||||
|
||||
return render(request, "addons/redirect_success.html")
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Add-ons API endpoints"""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
from rest_framework import decorators
|
||||
from rest_framework import status as drf_status
|
||||
from rest_framework import viewsets
|
||||
|
||||
from core.addons.service import TokenExchangeService, SessionOwnershipError
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class AuthSessionViewSet(viewsets.ViewSet):
|
||||
"""ViewSet for managing add-on authentication sessions via token exchange."""
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
throttle_classes = []
|
||||
|
||||
def create(self, request):
|
||||
"""Create a pending session.
|
||||
|
||||
Returns session_id in the body (client forwards it to the 3rd-party view).
|
||||
Sets result_token as an HttpOnly cookie (the only poll credential).
|
||||
"""
|
||||
session_id, result_token, transit_token = TokenExchangeService().init_session()
|
||||
response = drf_response.Response(
|
||||
{"session_id": session_id, "transit_token": transit_token}, status=drf_status.HTTP_201_CREATED
|
||||
)
|
||||
response.set_cookie(
|
||||
key=settings.ADDONS_RESULT_TOKEN_COOKIE_NAME,
|
||||
value=result_token,
|
||||
max_age=6000,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="None",
|
||||
)
|
||||
return response
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_name="wip",
|
||||
url_path="wip",
|
||||
permission_classes=[],
|
||||
authentication_classes=[],
|
||||
)
|
||||
def long_poll(self, request):
|
||||
"""Long-poll endpoint — only the cookie is accepted, never a session_id.
|
||||
|
||||
pk is intentionally ignored; the session is resolved from the cookie.
|
||||
"""
|
||||
|
||||
result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
|
||||
session_id = request.data.get("session_id")
|
||||
|
||||
if not result_token:
|
||||
return drf_response.Response(
|
||||
{"detail": "Missing result token."},
|
||||
status=drf_status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not session_id:
|
||||
return drf_response.Response(
|
||||
{"detail": "Missing result session id."},
|
||||
status=drf_status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
try:
|
||||
data = TokenExchangeService().get_session_by_token(
|
||||
result_token=result_token,
|
||||
claimed_session_id=session_id,
|
||||
)
|
||||
except SessionOwnershipError as e:
|
||||
raise SuspiciousOperation(str(e)) from e
|
||||
|
||||
if not data:
|
||||
return drf_response.Response(
|
||||
{"detail": "Session not found or expired."},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if data.get("state") == "pending":
|
||||
return drf_response.Response(
|
||||
{"state": "pending"},
|
||||
status=drf_status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
return drf_response.Response(data, status=drf_status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, pk=None):
|
||||
"""Explicit session teardown, resolves via cookie, not pk."""
|
||||
|
||||
result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
|
||||
if not result_token:
|
||||
return drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# We need the session_id to clear both keys — resolve it first
|
||||
session_id = TokenExchangeService().token_to_session(result_token)
|
||||
if session_id:
|
||||
TokenExchangeService().clear_session(session_id, result_token)
|
||||
|
||||
response = drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
|
||||
response.delete_cookie(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
|
||||
|
||||
return response
|
||||
@@ -1,27 +1,51 @@
|
||||
"""Authentication Backends for external application to the Meet core app."""
|
||||
|
||||
# pylint: disable=R0913,R0917
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import jwt as pyJwt
|
||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
from core.models import Application
|
||||
from core.services import jwt_token
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
"""JWT authentication for application-delegated API access.
|
||||
class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
"""Base JWT authentication class."""
|
||||
|
||||
Validates JWT tokens issued to applications that are acting on behalf
|
||||
of users. Tokens must include user_id, client_id, and delegation flag.
|
||||
"""
|
||||
def __init__(
|
||||
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
|
||||
):
|
||||
"""Initialize the JWT authentication backend with the given token service configuration.
|
||||
|
||||
Args:
|
||||
secret_key: Secret key for JWT encoding/decoding
|
||||
algorithm: JWT algorithm (e.g. HS256)
|
||||
issuer: Expected token issuer identifier
|
||||
audience: Expected token audience identifier
|
||||
expiration_seconds: Token expiration time in seconds
|
||||
token_type: Token type (e.g. Bearer)
|
||||
"""
|
||||
|
||||
super().__init__()
|
||||
|
||||
self._token_service = jwt_token.JwtTokenService(
|
||||
secret_key=secret_key,
|
||||
algorithm=algorithm,
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
expiration_seconds=expiration_seconds,
|
||||
token_type=token_type,
|
||||
)
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Extract and validate JWT from Authorization header.
|
||||
@@ -48,6 +72,78 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
|
||||
return self.authenticate_credentials(token)
|
||||
|
||||
def decode_jwt(self, token):
|
||||
"""Decode and validate JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Decoded payload dict, or None if token is invalid
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If token is expired or has invalid issuer/audience
|
||||
"""
|
||||
|
||||
try:
|
||||
payload = self._token_service.decode_jwt(token)
|
||||
return payload
|
||||
except jwt_token.TokenExpiredError as e:
|
||||
logger.warning("Token expired")
|
||||
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||
except jwt_token.TokenInvalidError as e:
|
||||
logger.warning("Invalid JWT issuer or audience: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except jwt_token.TokenDecodeError:
|
||||
# Invalid JWT token - defer to next authentication backend
|
||||
return None
|
||||
|
||||
def validate_payload(self, payload):
|
||||
"""Validate JWT payload claims.
|
||||
|
||||
Override in subclasses to add custom validation.
|
||||
|
||||
Args:
|
||||
payload: Decoded JWT payload
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If required claims are missing or invalid
|
||||
"""
|
||||
|
||||
def get_user(self, payload):
|
||||
"""Retrieve and validate user from payload.
|
||||
|
||||
Args:
|
||||
payload: Decoded JWT payload
|
||||
|
||||
Returns:
|
||||
User instance
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If user not found or inactive
|
||||
"""
|
||||
user_id = payload.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
logger.warning("Missing 'user_id' in JWT payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist as e:
|
||||
logger.warning("User not found: %s", user_id)
|
||||
raise exceptions.AuthenticationFailed("User not found.") from e
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user_id)
|
||||
raise exceptions.AuthenticationFailed("User account is disabled.")
|
||||
|
||||
return user
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return authentication scheme for WWW-Authenticate header."""
|
||||
return "Bearer"
|
||||
|
||||
def authenticate_credentials(self, token):
|
||||
"""Validate JWT token and return authenticated user.
|
||||
|
||||
@@ -62,36 +158,41 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
Raises:
|
||||
AuthenticationFailed: If token is expired, or user not found
|
||||
"""
|
||||
# Decode and validate JWT
|
||||
try:
|
||||
payload = pyJwt.decode(
|
||||
token,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithms=[settings.APPLICATION_JWT_ALG],
|
||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||
)
|
||||
except pyJwt.ExpiredSignatureError as e:
|
||||
logger.warning("Token expired")
|
||||
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||
except pyJwt.InvalidIssuerError as e:
|
||||
logger.warning("Invalid JWT issuer: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except pyJwt.InvalidAudienceError as e:
|
||||
logger.warning("Invalid JWT audience: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except pyJwt.InvalidTokenError:
|
||||
# Invalid JWT token - defer to next authentication backend
|
||||
|
||||
payload = self.decode_jwt(token)
|
||||
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
self.validate_payload(payload)
|
||||
user = self.get_user(payload)
|
||||
|
||||
return (user, payload)
|
||||
|
||||
|
||||
class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
||||
"""JWT authentication for application-delegated API access.
|
||||
|
||||
Validates JWT tokens issued to applications that are acting on behalf
|
||||
of users. Tokens must include user_id, client_id, and delegation flag.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize authentication backend with application JWT settings from Django settings."""
|
||||
super().__init__(
|
||||
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
def validate_payload(self, payload):
|
||||
"""Validate application-specific claims."""
|
||||
client_id = payload.get("client_id")
|
||||
is_delegated = payload.get("delegated", False)
|
||||
|
||||
if not user_id:
|
||||
logger.warning("Missing 'user_id' in JWT payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
|
||||
if not client_id:
|
||||
logger.warning("Missing 'client_id' in JWT payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
@@ -112,21 +213,24 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
logger.warning("Token is not marked as delegated")
|
||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist as e:
|
||||
logger.warning("User not found: %s", user_id)
|
||||
raise exceptions.AuthenticationFailed("User not found.") from e
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user_id)
|
||||
raise exceptions.AuthenticationFailed("User account is disabled.")
|
||||
class AddonsJWTAuthentication(BaseJWTAuthentication):
|
||||
"""JWT authentication for addons API access.
|
||||
|
||||
return (user, payload)
|
||||
Validates JWT tokens issued by addons for authenticating users.
|
||||
Tokens must include user_id to identify the authenticated user.
|
||||
"""
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return authentication scheme for WWW-Authenticate header."""
|
||||
return "Bearer"
|
||||
def __init__(self):
|
||||
"""Initialize authentication backend with application JWT settings from Django settings."""
|
||||
super().__init__(
|
||||
secret_key=settings.ADDONS_JWT_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_JWT_ALG,
|
||||
issuer=settings.ADDONS_JWT_ISSUER,
|
||||
audience=settings.ADDONS_JWT_AUDIENCE,
|
||||
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
|
||||
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
|
||||
class ResourceServerBackend(LaSuiteBackend):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""External API endpoints"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
@@ -8,7 +7,6 @@ from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
import jwt
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework import decorators, mixins, viewsets
|
||||
from rest_framework import (
|
||||
@@ -22,6 +20,7 @@ from rest_framework import (
|
||||
)
|
||||
|
||||
from core import api, models
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
|
||||
from . import authentication, permissions, serializers
|
||||
|
||||
@@ -128,33 +127,28 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
scope = " ".join(application.scopes or [])
|
||||
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||
"client_id": client_id,
|
||||
"scope": scope,
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
token_service = JwtTokenService(
|
||||
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
data = token_service.generate_jwt(
|
||||
user,
|
||||
scope,
|
||||
{
|
||||
"client_id": client_id,
|
||||
"delegated": True,
|
||||
},
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"access_token": token,
|
||||
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
"scope": scope,
|
||||
},
|
||||
data,
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -179,6 +173,7 @@ class RoomViewSet(
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
authentication.AddonsJWTAuthentication,
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""JWT token service."""
|
||||
|
||||
# pylint: disable=R0913,R0917
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import jwt
|
||||
|
||||
|
||||
class JWTError(Exception):
|
||||
"""Base exception for all JWT token errors."""
|
||||
|
||||
|
||||
class TokenExpiredError(JWTError):
|
||||
"""Raised when the JWT token has expired."""
|
||||
|
||||
|
||||
class TokenInvalidError(JWTError):
|
||||
"""Raised when the JWT token has an invalid issuer or audience."""
|
||||
|
||||
|
||||
class TokenDecodeError(JWTError):
|
||||
"""Raised for any other unrecoverable JWT decode failure."""
|
||||
|
||||
|
||||
class JwtTokenService:
|
||||
"""Generic JWT token service with configurable settings."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
secret_key: str,
|
||||
algorithm: str,
|
||||
issuer: str,
|
||||
audience: str,
|
||||
expiration_seconds: int,
|
||||
token_type: str,
|
||||
):
|
||||
"""
|
||||
Initialize the token service with custom settings.
|
||||
|
||||
Args:
|
||||
secret_key: Secret key for JWT encoding/decoding
|
||||
algorithm: JWT algorithm
|
||||
issuer: Token issuer identifier
|
||||
audience: Token audience identifier
|
||||
expiration_seconds: Token expiration time in seconds
|
||||
token_type: Token type
|
||||
|
||||
Raises:
|
||||
ImproperlyConfigured: If secret_key is None or empty
|
||||
"""
|
||||
if not secret_key:
|
||||
raise ImproperlyConfigured("Secret key is required.")
|
||||
if not algorithm:
|
||||
raise ImproperlyConfigured("Algorithm is required.")
|
||||
if not token_type:
|
||||
raise ImproperlyConfigured("Token's type is required.")
|
||||
if expiration_seconds is None:
|
||||
raise ImproperlyConfigured("Expiration's seconds is required.")
|
||||
|
||||
self._key = secret_key
|
||||
self._algorithm = algorithm
|
||||
self._issuer = issuer
|
||||
self._audience = audience
|
||||
self._expiration_seconds = expiration_seconds
|
||||
self._token_type = token_type
|
||||
|
||||
def generate_jwt(
|
||||
self, user, scope: str, extra_payload: Optional[dict] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Generate an access token for the given user.
|
||||
|
||||
Note: any extra_payload variables named iat, exp, or user_id will
|
||||
be overwritten by this service
|
||||
|
||||
Args:
|
||||
user: User instance for whom to generate the token
|
||||
scope: Space-separated scope string
|
||||
|
||||
Returns:
|
||||
Dictionary containing access_token, token_type, expires_in, and scope optionally
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = extra_payload.copy() if extra_payload else {}
|
||||
|
||||
payload.update(
|
||||
{
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=self._expiration_seconds),
|
||||
"user_id": str(user.id),
|
||||
}
|
||||
)
|
||||
|
||||
if self._issuer:
|
||||
payload["iss"] = self._issuer
|
||||
if self._audience:
|
||||
payload["aud"] = self._audience
|
||||
if scope:
|
||||
payload["scope"] = scope
|
||||
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
self._key,
|
||||
algorithm=self._algorithm,
|
||||
)
|
||||
|
||||
response = {
|
||||
"access_token": token,
|
||||
"token_type": self._token_type,
|
||||
"expires_in": self._expiration_seconds,
|
||||
}
|
||||
|
||||
if scope:
|
||||
response["scope"] = scope
|
||||
|
||||
return response
|
||||
|
||||
def decode_jwt(self, token):
|
||||
"""Decode and validate JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Decoded payload dict.
|
||||
|
||||
Raises:
|
||||
TokenExpiredError: If the token has expired.
|
||||
TokenInvalidError: If the token has an invalid issuer or audience.
|
||||
TokenDecodeError: If the token is malformed or cannot be decoded.
|
||||
"""
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self._key,
|
||||
algorithms=[self._algorithm],
|
||||
issuer=self._issuer,
|
||||
audience=self._audience,
|
||||
)
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
raise TokenExpiredError("Token expired.") from e
|
||||
except (jwt.InvalidIssuerError, jwt.InvalidAudienceError) as e:
|
||||
raise TokenInvalidError("Invalid token.") from e
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise TokenDecodeError("Token decode error.") from e
|
||||
@@ -0,0 +1,17 @@
|
||||
{% load i18n %}
|
||||
{% get_current_language as LANGUAGE %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ LANGUAGE }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% trans "Error" %}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>{{ title|default:_("Error") }}</h1>
|
||||
<p>{{ message|default:_("Something went wrong.") }}</p>
|
||||
<button onclick="window.close()">{% trans "Close" %}</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{% load i18n %}
|
||||
{% get_current_language as LANGUAGE %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ LANGUAGE }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% trans "Authentication Success" %}</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
window.close();
|
||||
</script>
|
||||
<p>{% trans "Session stored successfully. This window will close automatically." %}</p>
|
||||
<p>{% trans "If it doesn't close" %}, <a href="javascript:window.close()">{% trans "click here" %}</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -55,9 +55,8 @@ def test_api_rooms_list_requires_authentication():
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_list_inactive_user(settings):
|
||||
def test_api_rooms_list_inactive_user():
|
||||
"""List should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
@@ -72,11 +71,9 @@ def test_api_rooms_list_inactive_user(settings):
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_list_with_valid_token(settings):
|
||||
def test_api_rooms_list_with_valid_token():
|
||||
"""Listing rooms with valid token should succeed."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
@@ -92,9 +89,8 @@ def test_api_rooms_list_with_valid_token(settings):
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_list_with_no_rooms(settings):
|
||||
def test_api_rooms_list_with_no_rooms():
|
||||
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -112,7 +108,6 @@ def test_api_rooms_list_with_no_rooms(settings):
|
||||
|
||||
def test_api_rooms_list_with_expired_token(settings):
|
||||
"""Listing rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||
|
||||
user = UserFactory()
|
||||
@@ -153,9 +148,8 @@ def test_api_rooms_list_with_invalid_rs_token(settings):
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_list_missing_scope(settings):
|
||||
def test_api_rooms_list_missing_scope():
|
||||
"""Listing rooms without required scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -173,9 +167,8 @@ def test_api_rooms_list_missing_scope(settings):
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_list_no_scope(settings):
|
||||
def test_api_rooms_list_no_scope():
|
||||
"""Listing rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -190,9 +183,8 @@ def test_api_rooms_list_no_scope(settings):
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_list_filters_by_user(settings):
|
||||
def test_api_rooms_list_filters_by_user():
|
||||
"""List should only return rooms accessible to the authenticated user."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
@@ -217,11 +209,9 @@ def test_api_rooms_list_filters_by_user(settings):
|
||||
assert str(room2.id) not in returned_ids
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_authentication(settings):
|
||||
def test_api_rooms_retrieve_requires_authentication():
|
||||
"""Retrieving rooms without authentication should return 401."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory()
|
||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
|
||||
@@ -231,9 +221,8 @@ def test_api_rooms_retrieve_requires_authentication(settings):
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_inactive_user(settings):
|
||||
def test_api_rooms_retrieve_inactive_user():
|
||||
"""Retrieve should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
@@ -250,7 +239,6 @@ def test_api_rooms_retrieve_inactive_user(settings):
|
||||
|
||||
def test_api_rooms_retrieve_with_expired_token(settings):
|
||||
"""Retrieving rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||
|
||||
user = UserFactory()
|
||||
@@ -295,9 +283,8 @@ def test_api_rooms_retrieve_with_invalid_rs_token(settings):
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_scope(settings):
|
||||
def test_api_rooms_retrieve_requires_scope():
|
||||
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
@@ -315,9 +302,8 @@ def test_api_rooms_retrieve_requires_scope(settings):
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_no_scope(settings):
|
||||
def test_api_rooms_retrieve_no_scope():
|
||||
"""Retrieving rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -335,7 +321,6 @@ def test_api_rooms_retrieve_no_scope(settings):
|
||||
|
||||
def test_api_rooms_retrieve_success(settings):
|
||||
"""Retrieving a room with correct scope should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_BASE_URL = "http://your-application.com"
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
|
||||
@@ -367,9 +352,8 @@ def test_api_rooms_retrieve_success(settings):
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_success_by_user(settings):
|
||||
def test_api_rooms_retrieve_success_by_user():
|
||||
"""Retrieve should only return rooms accessible to the authenticated user."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
@@ -408,9 +392,8 @@ def test_api_rooms_retrieve_success_by_user(settings):
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_not_found(settings):
|
||||
def test_api_rooms_retrieve_not_found():
|
||||
"""Retrieving a non-existing room with correct scope should return a 404."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
@@ -423,11 +406,9 @@ def test_api_rooms_retrieve_not_found(settings):
|
||||
assert "no room matches the given query." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_requires_authentication(settings):
|
||||
def test_api_rooms_create_requires_authentication():
|
||||
"""Creating rooms without authentication should return 401."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
client = APIClient()
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
@@ -436,7 +417,6 @@ def test_api_rooms_create_requires_authentication(settings):
|
||||
|
||||
def test_api_rooms_create_with_expired_token(settings):
|
||||
"""Creating rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||
|
||||
user = UserFactory()
|
||||
@@ -477,9 +457,8 @@ def test_api_rooms_create_with_invalid_rs_token(settings):
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_create_inactive_user(settings):
|
||||
def test_api_rooms_create_inactive_user():
|
||||
"""Create should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
|
||||
@@ -493,9 +472,8 @@ def test_api_rooms_create_inactive_user(settings):
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_requires_scope(settings):
|
||||
def test_api_rooms_create_requires_scope():
|
||||
"""Creating a room requires ROOMS_CREATE scope."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
|
||||
# Token without ROOMS_CREATE scope
|
||||
@@ -512,9 +490,8 @@ def test_api_rooms_create_requires_scope(settings):
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_create_no_scope(settings):
|
||||
def test_api_rooms_create_no_scope():
|
||||
"""Creating rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -529,9 +506,8 @@ def test_api_rooms_create_no_scope(settings):
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_success(settings):
|
||||
def test_api_rooms_create_success():
|
||||
"""Creating a room with correct scope should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -555,9 +531,8 @@ def test_api_rooms_create_success(settings):
|
||||
assert room.access_level == "trusted"
|
||||
|
||||
|
||||
def test_api_rooms_create_readonly_enforcement(settings):
|
||||
def test_api_rooms_create_readonly_enforcement():
|
||||
"""Creating a room succeeds and any provided read-only fields are ignored."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -590,9 +565,8 @@ def test_api_rooms_create_readonly_enforcement(settings):
|
||||
assert room.access_level == "trusted"
|
||||
|
||||
|
||||
def test_api_rooms_unknown_actions(settings):
|
||||
def test_api_rooms_unknown_actions():
|
||||
"""Updating or deleting a room are not supported yet."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
@@ -623,7 +597,6 @@ def test_api_rooms_unknown_actions(settings):
|
||||
|
||||
def test_api_rooms_response_no_url(settings):
|
||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_BASE_URL = None
|
||||
|
||||
user = UserFactory()
|
||||
@@ -642,7 +615,6 @@ def test_api_rooms_response_no_url(settings):
|
||||
|
||||
def test_api_rooms_response_no_telephony(settings):
|
||||
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
@@ -661,7 +633,6 @@ def test_api_rooms_response_no_telephony(settings):
|
||||
|
||||
def test_api_rooms_token_scope_case_insensitive(settings):
|
||||
"""Token's scope should be case-insensitive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
@@ -693,7 +664,6 @@ def test_api_rooms_token_scope_case_insensitive(settings):
|
||||
|
||||
def test_api_rooms_token_without_delegated_flag(settings):
|
||||
"""Token without delegated flag should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
@@ -726,7 +696,6 @@ def test_api_rooms_token_without_delegated_flag(settings):
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
||||
"""Token signed with an invalid key should defer to the next authentication."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
@@ -759,7 +728,6 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
|
||||
"""Token signed with an invalid alg should defer to the next authentication."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_ALG = "RS256"
|
||||
user = UserFactory()
|
||||
|
||||
@@ -791,7 +759,6 @@ def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
|
||||
|
||||
def test_api_rooms_token_missing_client_id(settings):
|
||||
"""Token without client_id should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -821,7 +788,6 @@ def test_api_rooms_token_missing_client_id(settings):
|
||||
|
||||
def test_api_rooms_token_missing_user_id(settings):
|
||||
"""Token without user_id should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -851,7 +817,6 @@ def test_api_rooms_token_missing_user_id(settings):
|
||||
|
||||
def test_api_rooms_token_invalid_audience(settings):
|
||||
"""Token with an invalid audience should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
@@ -882,7 +847,6 @@ def test_api_rooms_token_invalid_audience(settings):
|
||||
|
||||
def test_api_rooms_token_unknown_user(settings):
|
||||
"""Token for unknown user should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -912,7 +876,6 @@ def test_api_rooms_token_unknown_user(settings):
|
||||
|
||||
def test_api_rooms_token_unknown_application(settings):
|
||||
"""Token for unknown application should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
@@ -941,7 +904,6 @@ def test_api_rooms_token_unknown_application(settings):
|
||||
|
||||
def test_api_rooms_token_inactive_application(settings):
|
||||
"""Token for inactive application should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory(active=False)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -21,7 +21,6 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
UserFactory(email="User.Family@example.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
@@ -173,9 +172,8 @@ def test_api_applications_generate_token_domain_not_authorized():
|
||||
assert "not authorized for this email domain" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_domain_authorized(settings):
|
||||
def test_api_applications_generate_token_domain_authorized():
|
||||
"""Application with domain authorization should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory(email="user@allowed.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
@@ -230,7 +228,6 @@ def test_api_applications_generate_token_user_not_found():
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_api_applications_token_payload_structure(settings):
|
||||
"""Generated token should have correct payload structure."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory(email="user@example.com")
|
||||
|
||||
application = ApplicationFactory(
|
||||
@@ -280,7 +277,6 @@ def test_api_applications_token_payload_structure(settings):
|
||||
def test_api_applications_token_new_user(settings):
|
||||
"""Should create a new pending user when creation is allowed and user doesn't exist."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_ALLOW_USER_CREATION = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||
@@ -337,7 +333,6 @@ def test_api_applications_token_new_user(settings):
|
||||
def test_api_applications_token_existing_user(settings):
|
||||
"""Application should not create a new user when user exist."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory(email="user@example.com")
|
||||
|
||||
settings.APPLICATION_ALLOW_USER_CREATION = True
|
||||
|
||||
@@ -6,6 +6,8 @@ from django.urls import include, path
|
||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.addons import views as addons_views
|
||||
from core.addons import viewsets as addons_viewsets
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
|
||||
@@ -26,12 +28,24 @@ external_router.register(
|
||||
basename="external_application",
|
||||
)
|
||||
|
||||
# - Addons API
|
||||
addons_router = DefaultRouter()
|
||||
addons_router.register(
|
||||
"addons/sessions",
|
||||
addons_viewsets.AuthSessionViewSet,
|
||||
basename="addons_auth_sessions",
|
||||
)
|
||||
|
||||
external_router.register(
|
||||
"rooms",
|
||||
external_viewsets.RoomViewSet,
|
||||
basename="external_room",
|
||||
)
|
||||
|
||||
|
||||
addons_urls = addons_router.urls if settings.ADDONS_ENABLED else []
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/",
|
||||
@@ -39,12 +53,26 @@ urlpatterns = [
|
||||
[
|
||||
*router.urls,
|
||||
*oidc_urls,
|
||||
*addons_urls, # should be in external api
|
||||
path("config/", get_frontend_configuration, name="config"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
if settings.ADDONS_ENABLED:
|
||||
urlpatterns.append(
|
||||
path(
|
||||
"addons/",
|
||||
include(
|
||||
[
|
||||
path("transit/", addons_views.transit_page, name="transit_page"),
|
||||
path("redirect/", addons_views.redirect_page, name="redirect_page"),
|
||||
]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if settings.EXTERNAL_API_ENABLED:
|
||||
urlpatterns.append(
|
||||
path(
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,6 +17,30 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/addons/views.py:24
|
||||
msgid "Session ID is required."
|
||||
msgstr "Sitzungs-ID ist erforderlich."
|
||||
|
||||
#: core/addons/views.py:29
|
||||
msgid "Session not found or expired."
|
||||
msgstr "Sitzung nicht gefunden oder abgelaufen."
|
||||
|
||||
#: core/addons/views.py:32
|
||||
msgid "Invalid session state."
|
||||
msgstr "Ungültiger Sitzungsstatus."
|
||||
|
||||
#: core/addons/views.py:45
|
||||
msgid "Authentication required."
|
||||
msgstr "Authentifizierung erforderlich."
|
||||
|
||||
#: core/addons/views.py:50
|
||||
msgid "No active session found."
|
||||
msgstr "Keine aktive Sitzung gefunden."
|
||||
|
||||
#: core/addons/views.py:55
|
||||
msgid "Invalid or expired session."
|
||||
msgstr "Ungültige oder abgelaufene Sitzung."
|
||||
|
||||
#: core/admin.py:29
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Informationen"
|
||||
@@ -408,7 +432,7 @@ msgstr "Anwendungsdomain"
|
||||
msgid "Application domains"
|
||||
msgstr "Anwendungsdomains"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
#: core/recording/event/notification.py:116
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Ihre Aufzeichnung ist bereit"
|
||||
|
||||
@@ -417,6 +441,30 @@ msgstr "Ihre Aufzeichnung ist bereit"
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
|
||||
|
||||
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
||||
msgid "Error"
|
||||
msgstr "Fehler"
|
||||
|
||||
#: core/templates/addons/error.html:12
|
||||
msgid "Something went wrong."
|
||||
msgstr "Etwas ist schiefgelaufen."
|
||||
|
||||
#: core/templates/addons/error.html:13
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
#: core/templates/addons/redirect_success.html:7
|
||||
msgid "Authentication Success"
|
||||
msgstr "Authentifizierung erfolgreich"
|
||||
|
||||
#: core/templates/addons/redirect_success.html:13
|
||||
msgid "Session stored successfully. This window will close automatically."
|
||||
msgstr "Sitzung erfolgreich gespeichert. Dieses Fenster wird automatisch geschlossen."
|
||||
|
||||
#: core/templates/addons/redirect_success.html:14
|
||||
msgid "If it doesn't close"
|
||||
msgstr "Falls es sich nicht schließt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,6 +17,30 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/addons/views.py:24
|
||||
msgid "Session ID is required."
|
||||
msgstr "Session ID is required."
|
||||
|
||||
#: core/addons/views.py:29
|
||||
msgid "Session not found or expired."
|
||||
msgstr "Session not found or expired."
|
||||
|
||||
#: core/addons/views.py:32
|
||||
msgid "Invalid session state."
|
||||
msgstr "Invalid session state."
|
||||
|
||||
#: core/addons/views.py:45
|
||||
msgid "Authentication required."
|
||||
msgstr "Authentication required."
|
||||
|
||||
#: core/addons/views.py:50
|
||||
msgid "No active session found."
|
||||
msgstr "No active session found."
|
||||
|
||||
#: core/addons/views.py:55
|
||||
msgid "Invalid or expired session."
|
||||
msgstr "Invalid or expired session."
|
||||
|
||||
#: core/admin.py:29
|
||||
msgid "Personal info"
|
||||
msgstr "Personal info"
|
||||
@@ -405,7 +429,7 @@ msgstr "Application domain"
|
||||
msgid "Application domains"
|
||||
msgstr "Application domains"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
#: core/recording/event/notification.py:116
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Your recording is ready"
|
||||
|
||||
@@ -414,6 +438,30 @@ msgstr "Your recording is ready"
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
|
||||
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
||||
msgid "Error"
|
||||
msgstr "Error"
|
||||
|
||||
#: core/templates/addons/error.html:12
|
||||
msgid "Something went wrong."
|
||||
msgstr "Something went wrong."
|
||||
|
||||
#: core/templates/addons/error.html:13
|
||||
msgid "Close"
|
||||
msgstr "Close"
|
||||
|
||||
#: core/templates/addons/redirect_success.html:7
|
||||
msgid "Authentication Success"
|
||||
msgstr "Authentication Success"
|
||||
|
||||
#: core/templates/addons/redirect_success.html:13
|
||||
msgid "Session stored successfully. This window will close automatically."
|
||||
msgstr "Session stored successfully. This window will close automatically."
|
||||
|
||||
#: core/templates/addons/redirect_success.html:14
|
||||
msgid "If it doesn't close"
|
||||
msgstr "If it doesn't close"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,6 +17,30 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/addons/views.py:24
|
||||
msgid "Session ID is required."
|
||||
msgstr "L'identifiant de session est requis."
|
||||
|
||||
#: core/addons/views.py:29
|
||||
msgid "Session not found or expired."
|
||||
msgstr "Session introuvable ou expirée."
|
||||
|
||||
#: core/addons/views.py:32
|
||||
msgid "Invalid session state."
|
||||
msgstr "État de session invalide."
|
||||
|
||||
#: core/addons/views.py:45
|
||||
msgid "Authentication required."
|
||||
msgstr "Authentification requise."
|
||||
|
||||
#: core/addons/views.py:50
|
||||
msgid "No active session found."
|
||||
msgstr "Aucune session active trouvée."
|
||||
|
||||
#: core/addons/views.py:55
|
||||
msgid "Invalid or expired session."
|
||||
msgstr "Session invalide ou expirée."
|
||||
|
||||
#: core/admin.py:29
|
||||
msgid "Personal info"
|
||||
msgstr "Informations personnelles"
|
||||
@@ -409,7 +433,7 @@ msgstr "Domaine d’application"
|
||||
msgid "Application domains"
|
||||
msgstr "Domaines d’application"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
#: core/recording/event/notification.py:116
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Votre enregistrement est prêt"
|
||||
|
||||
@@ -418,6 +442,30 @@ msgstr "Votre enregistrement est prêt"
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
|
||||
|
||||
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
||||
msgid "Error"
|
||||
msgstr "Erreur"
|
||||
|
||||
#: core/templates/addons/error.html:12
|
||||
msgid "Something went wrong."
|
||||
msgstr "Une erreur s'est produite."
|
||||
|
||||
#: core/templates/addons/error.html:13
|
||||
msgid "Close"
|
||||
msgstr "Fermer"
|
||||
|
||||
#: core/templates/addons/redirect_success.html:7
|
||||
msgid "Authentication Success"
|
||||
msgstr "Authentification réussie"
|
||||
|
||||
#: core/templates/addons/redirect_success.html:13
|
||||
msgid "Session stored successfully. This window will close automatically."
|
||||
msgstr "Session enregistrée avec succès. Cette fenêtre se fermera automatiquement."
|
||||
|
||||
#: core/templates/addons/redirect_success.html:14
|
||||
msgid "If it doesn't close"
|
||||
msgstr "Si elle ne se ferme pas"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,6 +17,30 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/addons/views.py:24
|
||||
msgid "Session ID is required."
|
||||
msgstr "Sessie-ID is vereist."
|
||||
|
||||
#: core/addons/views.py:29
|
||||
msgid "Session not found or expired."
|
||||
msgstr "Sessie niet gevonden of verlopen."
|
||||
|
||||
#: core/addons/views.py:32
|
||||
msgid "Invalid session state."
|
||||
msgstr "Ongeldige sessiestatus."
|
||||
|
||||
#: core/addons/views.py:45
|
||||
msgid "Authentication required."
|
||||
msgstr "Authenticatie vereist."
|
||||
|
||||
#: core/addons/views.py:50
|
||||
msgid "No active session found."
|
||||
msgstr "Geen actieve sessie gevonden."
|
||||
|
||||
#: core/addons/views.py:55
|
||||
msgid "Invalid or expired session."
|
||||
msgstr "Ongeldige of verlopen sessie."
|
||||
|
||||
#: core/admin.py:29
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke informatie"
|
||||
@@ -404,7 +428,7 @@ msgstr "Applicatiedomein"
|
||||
msgid "Application domains"
|
||||
msgstr "Applicatiedomeinen"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
#: core/recording/event/notification.py:116
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Je opname is klaar"
|
||||
|
||||
@@ -413,6 +437,30 @@ msgstr "Je opname is klaar"
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
|
||||
|
||||
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
||||
msgid "Error"
|
||||
msgstr "Fout"
|
||||
|
||||
#: core/templates/addons/error.html:12
|
||||
msgid "Something went wrong."
|
||||
msgstr "Er is iets misgegaan."
|
||||
|
||||
#: core/templates/addons/error.html:13
|
||||
msgid "Close"
|
||||
msgstr "Sluiten"
|
||||
|
||||
#: core/templates/addons/redirect_success.html:7
|
||||
msgid "Authentication Success"
|
||||
msgstr "Authenticatie geslaagd"
|
||||
|
||||
#: core/templates/addons/redirect_success.html:13
|
||||
msgid "Session stored successfully. This window will close automatically."
|
||||
msgstr "Sessie succesvol opgeslagen. Dit venster wordt automatisch gesloten."
|
||||
|
||||
#: core/templates/addons/redirect_success.html:14
|
||||
msgid "If it doesn't close"
|
||||
msgstr "Als het niet sluit"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
|
||||
@@ -797,6 +797,93 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Addons
|
||||
ADDONS_ENABLED = values.BooleanValue(
|
||||
False,
|
||||
environ_name="ADDONS_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_SESSION_ID_LENGTH = values.PositiveIntegerValue(
|
||||
32,
|
||||
environ_name="ADDONS_SESSION_ID_LENGTH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Cache key for the session itself (session_id → session data)
|
||||
ADDONS_SESSION_KEY_PREFIX = values.Value(
|
||||
"addons_session_id",
|
||||
environ_name="ADDONS_SESSION_KEY_PREFIX",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Cache key for the token → session binding (result_token → session_id)
|
||||
ADDONS_SESSION_TOKEN_PREFIX = values.Value(
|
||||
"addons_token_id",
|
||||
environ_name="ADDONS_SESSION_TOKEN_PREFIX",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Used as the Django session key in transit page
|
||||
ADDONS_SESSION_KEY_AUTH = values.Value(
|
||||
"addons_session_id",
|
||||
environ_name="ADDONS_SESSION_KEY_AUTH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_SESSION_TIMEOUT = values.PositiveIntegerValue(
|
||||
600, environ_name="ADDONS_SESSION_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
ADDONS_RESULT_TOKEN_COOKIE_NAME = values.Value(
|
||||
"wip",
|
||||
environ_name="ADDONS_RESULT_TOKEN_COOKIE_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_RESULT_TOKEN_COOKIE_SECURE = values.BooleanValue(
|
||||
True,
|
||||
environ_name="ADDONS_RESULT_TOKEN_COOKIE_SECURE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_RESULT_TOKEN_COOKIE_HTTP_ONLY = values.BooleanValue(
|
||||
True,
|
||||
environ_name="ADDONS_RESULT_TOKEN_COOKIE_HTTP_ONLY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_RESULT_TOKEN_COOKIE_SAMESITE = values.Value(
|
||||
"strict",
|
||||
environ_name="ADDONS_RESULT_TOKEN_COOKIE_SAMESITE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
ADDONS_JWT_SECRET_KEY = SecretFileValue(
|
||||
None, environ_name="ADDONS_JWT_SECRET_KEY", environ_prefix=None
|
||||
)
|
||||
ADDONS_JWT_ALG = values.Value(
|
||||
"HS256",
|
||||
environ_name="ADDONS_JWT_ALG",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_SCOPES = values.Value(
|
||||
"rooms:create rooms:list",
|
||||
environ_name="ADDONS_SCOPES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_JWT_ISSUER = values.Value(
|
||||
"lasuite-meet",
|
||||
environ_name="ADDONS_JWT_ISSUER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_JWT_AUDIENCE = values.Value(
|
||||
None,
|
||||
environ_name="ADDONS_JWT_AUDIENCE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
|
||||
3600,
|
||||
environ_name="ADDONS_JWT_EXPIRATION_SECONDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_JWT_TOKEN_TYPE = values.Value(
|
||||
"Bearer",
|
||||
environ_name="ADDONS_JWT_TOKEN_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -917,6 +1004,9 @@ class Test(Base):
|
||||
USE_SWAGGER = True
|
||||
EXTERNAL_API_ENABLED = True
|
||||
|
||||
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
|
||||
APPLICATION_JWT_AUDIENCE = "Test inc."
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@@ -11,6 +11,41 @@ server {
|
||||
add_header Content-Disposition "attachment; filename=windows-app-web-link";
|
||||
}
|
||||
|
||||
|
||||
# Manifest — fetched, never iframed
|
||||
location = /outlook-addin/manifest.xml {
|
||||
alias /usr/share/nginx/html/outlook-addin/manifest.xml;
|
||||
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header X-Frame-Options "DENY";
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'";
|
||||
}
|
||||
|
||||
location ~ ^/outlook-addin(/.*)?$ {
|
||||
alias /usr/share/nginx/html/outlook-addin$1;
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
|
||||
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
|
||||
|
||||
set $nonce $request_id;
|
||||
|
||||
set $csp "upgrade-insecure-requests; ";
|
||||
set $csp "${csp}frame-ancestors ${ms_domains}; ";
|
||||
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic' ${ms_domains} 'self'; ";
|
||||
set $csp "${csp}connect-src 'self' 'strict-dynamic' ${ms_domains}; ";
|
||||
set $csp "${csp}frame-src 'none'; ";
|
||||
set $csp "${csp}object-src 'none'; ";
|
||||
set $csp "${csp}base-uri 'none'; ";
|
||||
|
||||
add_header Content-Security-Policy $csp;
|
||||
|
||||
sub_filter 'NONCE_PLACEHOLDER' $nonce;
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html;
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
|
||||
@@ -79,6 +79,7 @@ backend:
|
||||
APPLICATION_JWT_AUDIENCE: https://meet.127.0.0.1.nip.io/external-api/v1.0/
|
||||
APPLICATION_JWT_SECRET_KEY: devKeyApplication
|
||||
APPLICATION_BASE_URL: https://meet.127.0.0.1.nip.io
|
||||
ADDONS_JWT_SECRET_KEY: devKeyApplicationAddons
|
||||
|
||||
|
||||
migrate:
|
||||
|
||||
@@ -88,6 +88,20 @@ spec:
|
||||
serviceName: {{ include "meet.backend.fullname" . }}
|
||||
servicePort: {{ .Values.backend.service.port }}
|
||||
{{- end }}
|
||||
- path: /addons/
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Prefix
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "meet.backend.fullname" . }}
|
||||
port:
|
||||
number: {{ .Values.backend.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "meet.backend.fullname" . }}
|
||||
servicePort: {{ .Values.backend.service.port }}
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.customBackends }}
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
@@ -138,6 +152,20 @@ spec:
|
||||
serviceName: {{ include "meet.backend.fullname" $ }}
|
||||
servicePort: {{ $.Values.backend.service.port }}
|
||||
{{- end }}
|
||||
- path: /addons/
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Prefix
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "meet.backend.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.backend.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "meet.backend.fullname" $ }}
|
||||
servicePort: {{ $.Values.backend.service.port }}
|
||||
{{- end }}
|
||||
{{- with $.Values.ingress.customBackends }}
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
|
||||
Reference in New Issue
Block a user