Version 2.1.0

This commit is contained in:
Alex
2024-08-23 16:52:35 +02:00
parent a646ce3914
commit 8099fa8db5
153 changed files with 8467 additions and 2964 deletions

View File

@@ -36,4 +36,17 @@ MAIL_FROM_NAME=Claper
# Claper configuration # Claper configuration
#ENABLE_ACCOUNT_CREATION=true #ENABLE_ACCOUNT_CREATION=true
#GS_JPG_RESOLUTION=300x300 #ALLOW_UNLINK_EXTERNAL_PROVIDER=false
#LOGOUT_REDIRECT_URL=https://google.com
#GS_JPG_RESOLUTION=300x300
# OIDC configuration
# OIDC_PROVIDER_NAME="OpenID"
# OIDC_ISSUER=https://my-idp.example/application/o/claper/
# OIDC_CLIENT_ID=XXX
# OIDC_CLIENT_SECRET=XXX
# OIDC_SCOPES="openid email profile"
# OIDC_LOGO_URL=""
# OIDC_PROPERTY_MAPPINGS="roles:custom_attributes.roles,organization:custom_attributes.organization"
# OIDC_AUTO_REDIRECT_LOGIN=true

1
.gitignore vendored
View File

@@ -42,3 +42,4 @@ test/e2e/node_modules
.DS_Store .DS_Store
priv/static/.well-known/apple-developer-merchantid-domain-association priv/static/.well-known/apple-developer-merchantid-domain-association
priv/static/loaderio-eb3b956a176cdd4f54eb8570ce8bbb06.txt priv/static/loaderio-eb3b956a176cdd4f54eb8570ce8bbb06.txt
.elixir_ls

View File

@@ -1,3 +1,21 @@
## v2.1.0
### Features
- LTI 1.3 integration (Beta)
- OpenID Connect integration
- New layout for presentation manager
- Duplicate event feature
### Fixes and improvements
- Improve embeds integration for better compatibility with different providers
- Add an option to polls to show results to attendees
### Fixes and improvements
- Fix input length validation for polls
## v2.0.1 ## v2.0.1
### Features ### Features

View File

@@ -117,9 +117,10 @@ ENV MIX_ENV="prod"
# Only copy the final release from the build stage # Only copy the final release from the build stage
COPY --from=builder --chmod=a+rX /app/_build/prod/rel/claper /app COPY --from=builder --chmod=a+rX /app/_build/prod/rel/claper /app
COPY --from=builder /app/priv/repo/seeds.exs /app/priv/repo/
RUN mkdir /app/uploads && chmod -R 777 /app/uploads RUN mkdir /app/uploads && chmod -R 777 /app/uploads
EXPOSE 4000 EXPOSE 4000
WORKDIR "/app" WORKDIR "/app"
USER root USER root
CMD ["sh", "-c", "/app/bin/claper eval Claper.Release.migrate && /app/bin/claper start"] CMD ["sh", "-c", "/app/bin/claper eval Claper.Release.migrate && /app/bin/claper eval Claper.Release.seeds && /app/bin/claper start"]

View File

@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble Preamble
The GNU General Public License is a free, copyleft license for The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works. software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the software for all its users.
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things. free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you Developers that use our General Public Licenses protect your rights
these rights or asking you to surrender the rights. Therefore, you have with two steps: (1) assert copyright on the software, and (2) offer
certain responsibilities if you distribute copies of the software, or if you this License which gives you legal permission to copy, distribute
you modify it: responsibilities to respect the freedom of others. and/or modify the software.
For example, if you distribute copies of such a program, whether A secondary benefit of defending all users' freedom is that
gratis or for a fee, you must pass on to the recipients the same improvements made in alternate versions of the program, if they
freedoms that you received. You must make sure that they, too, receive receive widespread use, become available for other developers to
or can get the source code. And you must show them these terms so they incorporate. Many developers of free software are heartened and
know their rights. encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps: The GNU Affero General Public License is designed specifically to
(1) assert copyright on the software, and (2) offer you this License ensure that, in such cases, the modified source code becomes available
giving you legal permission to copy, distribute and/or modify it. to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains An older license, called the Affero General Public License and
that there is no warranty for this free software. For both users' and published by Affero, was designed to accomplish similar goals. This is
authors' sake, the GPL requires that modified versions be marked as a different license, not a version of the Affero GPL, but Affero has
changed, so that their problems will not be attributed erroneously to released a new version of the Affero GPL which permits relicensing under
authors of previous versions. this license.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and The precise terms and conditions for copying, distribution and
modification follow. modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions. 0. Definitions.
"This License" refers to version 3 of the GNU General Public License. "This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks. works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program. License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License. 13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work, License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License, but the work with which it is combined will remain governed by version
section 13, concerning interaction through a network will apply to the 3 of the GNU General Public License.
combination as such.
14. Revised Versions of this License. 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will the GNU Affero General Public License from time to time. Such new versions
be similar in spirit to the present version, but may differ in detail to will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns. address new problems or concerns.
Each version is given a distinguishing version number. If the Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation. by the Free Software Foundation.
If the Program specifies that a proxy can decide which future If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you public statement of acceptance of a version permanently authorizes you
to choose that version for the Program. to choose that version for the Program.
@@ -631,44 +629,33 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found. the "copyright" line and a pointer to where the full notice is found.
Claper, tool to let your audience interact during your presentations. <one line to give the program's name and a brief idea of what it does.>
Copyright (C) 2022 Alexandre Lion Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU Affero General Public License as published
the Free Software Foundation, either version 3 of the License, or by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short If your software can interact with users remotely through a computer
notice like this when it starts in an interactive mode: network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
Claper Copyright (C) 2022 Alexandre Lion interface could display a "Source" link that leads users to an archive
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. of the code. There are many ways you could offer source, and different
This is free software, and you are welcome to redistribute it solutions will be better for different programs; see section 13 for the
under certain conditions; type `show c' for details. specific requirements.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school, You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -42,9 +42,15 @@ Claper is proudly powered by Phoenix and Elixir.
[![Phoenix][Phoenix]][Phoenix-url] [![Elixir][Elixir]][Elixir-url] [![Tailwind][Tailwind]][Tailwind-url] [![Phoenix][Phoenix]][Phoenix-url] [![Elixir][Elixir]][Elixir-url] [![Tailwind][Tailwind]][Tailwind-url]
### Our partners and sponsors
<a href="https://www.lmddc.lu/"><img src="priv/static/images/partners/lmddc.png" alt="LMDDC" height="50"></a>
<a href="https://www.pixilearn.fr/en/"><img src="priv/static/images/partners/pixilearn.png" alt="Pixilearn" height="50"></a>
<a href="https://www.uccs.edu/"><img src="priv/static/images/partners/uccs.png" alt="UCCS" height="50"></a>
## Documentation ## Documentation
You can find all the instructions and configuration in [the documentation](https://docs.claper.co/configuration.html). You can find all the instructions and configuration in [the documentation](https://docs.claper.co/).
## Development environment ## Development environment
@@ -112,11 +118,15 @@ Distributed under the GPLv3 License. See `LICENSE.txt` for more information.
<!-- CONTACT --> <!-- CONTACT -->
## Contact ## Links
[![](https://img.shields.io/badge/@alxlion__-000000?style=for-the-badge&logo=x&logoColor=white)](https://x.com/alxlion_) [![](https://img.shields.io/badge/ClaperCo/Claper-000000?style=for-the-badge&logo=Github&logoColor=white)](https://github.com/ClaperCo/Claper)
Project Link: [https://github.com/ClaperCo/Claper](https://github.com/ClaperCo/Claper) [![](https://img.shields.io/badge/Discord-5052db?style=for-the-badge&logo=Discord&logoColor=white)](https://discord.gg/M7ejVaC9gA)
[![](https://img.shields.io/badge//r/claper-ed491a?style=for-the-badge&logo=Reddit&logoColor=white)](https://reddit.com/r/claper)
[![](<https://img.shields.io/badge/Alex_Lion_(Founder)-000000?style=for-the-badge&logo=x&logoColor=white>)](https://x.com/alxlion_)
<!-- MARKDOWN LINKS & IMAGES --> <!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links --> <!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
@@ -138,3 +148,6 @@ Project Link: [https://github.com/ClaperCo/Claper](https://github.com/ClaperCo/C
[Tailwind-url]: https://tailwindcss.com/ [Tailwind-url]: https://tailwindcss.com/
[Phoenix]: https://img.shields.io/badge/phoenix-f35424?style=for-the-badge&logo=&logoColor=white [Phoenix]: https://img.shields.io/badge/phoenix-f35424?style=for-the-badge&logo=&logoColor=white
[Phoenix-url]: https://www.phoenixframework.org/ [Phoenix-url]: https://www.phoenixframework.org/
[lmddc-logo]: /priv/static/images/partners/lmddc.png
[pixilearn-logo]: /priv/static/images/partners/pixilearn.png
[uccs-logo]: /priv/static/images/partners/uccs.png

View File

@@ -494,7 +494,6 @@ Hooks.QRCode = {
Hooks.Dropdown = { Hooks.Dropdown = {
mounted() { mounted() {
this.el.addEventListener("click", (e) => { this.el.addEventListener("click", (e) => {
e.preventDefault();
this.el.classList.toggle("hidden"); this.el.classList.toggle("hidden");
}); });
}, },

View File

@@ -1,82 +1,176 @@
import { tns } from "tiny-slider" import { tns } from "tiny-slider";
export class Manager { export class Manager {
constructor(context) { constructor(context) {
this.context = context this.context = context;
this.currentPage = parseInt(context.el.dataset.currentPage) this.currentPage = parseInt(context.el.dataset.currentPage);
this.maxPage = parseInt(context.el.dataset.maxPage) this.maxPage = parseInt(context.el.dataset.maxPage);
} }
init() { init() {
this.context.handleEvent("page-manage", (data) => {
this.context.handleEvent('page-manage', data => { var el = document.getElementById("slide-preview-" + data.current_page);
var el = document.getElementById("slide-preview-" + data.current_page)
if (el) { if (el) {
setTimeout(() => { setTimeout(
document.getElementById("slides").scrollTo({ () => {
top: el.offsetTop - el.scrollHeight, const slidesLayout = document.getElementById("slides-layout");
left: 0, const layoutWidth = slidesLayout.clientWidth;
behavior: 'smooth' const elementWidth = el.children[0].scrollWidth;
}); const scrollPosition =
}, data.timeout ? data.timeout : 0) el.children[0].offsetLeft - layoutWidth / 2 + elementWidth / 2;
slidesLayout.scrollTo({
left: scrollPosition,
});
},
data.timeout ? data.timeout : 0
);
} }
}) });
window.addEventListener('keydown', (e) => { window.addEventListener("keydown", (e) => {
if ((e.target.tagName || "").toLowerCase() != "input") {
if ((e.target.tagName || '').toLowerCase() != "input") { e.preventDefault();
e.preventDefault()
switch (e.key) { switch (e.key) {
case 'ArrowUp': case "ArrowUp":
this.prevPage() this.prevPage();
break break;
case 'ArrowLeft': case "ArrowLeft":
this.prevPage() this.prevPage();
break break;
case 'ArrowRight': case "ArrowRight":
this.nextPage() this.nextPage();
break break;
case 'ArrowDown': case "ArrowDown":
this.nextPage() this.nextPage();
break break;
} }
} }
}); });
this.initPreview();
} }
update() { initPreview() {
this.currentPage = parseInt(this.context.el.dataset.currentPage) var preview = document.getElementById("preview");
var el = document.getElementById("slide-preview-" + this.currentPage)
if (el) { if (preview) {
setTimeout(() => { let isDragging = false;
document.getElementById("slides").scrollTo({ let startX, startY;
top: el.offsetTop - el.scrollHeight,
left: 0, let originalSnap = localStorage.getItem("preview-position");
behavior: 'smooth' if (originalSnap) {
}); let snaps = originalSnap.split(":");
}, 50) preview.style.left = `${snaps[0]}px`;
preview.style.top = `${snaps[1]}px`;
}
const startDrag = (e) => {
isDragging = true;
startX = (e.clientX || e.touches[0].clientX) - preview.offsetLeft;
startY = (e.clientY || e.touches[0].clientY) - preview.offsetTop;
};
const drag = (e) => {
if (!isDragging) return;
e.preventDefault();
const clientX = e.clientX || e.touches[0].clientX;
const clientY = e.clientY || e.touches[0].clientY;
const newX = clientX - startX;
const newY = clientY - startY;
preview.style.left = `${newX}px`;
preview.style.top = `${newY}px`;
};
const endDrag = () => {
if (!isDragging) return;
isDragging = false;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const previewRect = preview.getBoundingClientRect();
const padding = 20; // Add 20px padding
let snapX, snapY;
if (previewRect.left < windowWidth / 2) {
snapX = padding;
} else {
snapX = windowWidth - previewRect.width - padding;
}
if (previewRect.top < windowHeight / 2) {
snapY = padding;
} else {
snapY = windowHeight - previewRect.height - padding;
}
preview.style.transition = "left 0.3s ease-out, top 0.3s ease-out";
preview.style.left = `${snapX}px`;
preview.style.top = `${snapY}px`;
localStorage.setItem("preview-position", `${snapX}:${snapY}`);
// Remove the transition after it's complete
setTimeout(() => {
preview.style.transition = "";
}, 300);
};
preview.addEventListener("mousedown", startDrag);
preview.addEventListener("touchstart", startDrag);
document.addEventListener("mousemove", drag);
document.addEventListener("touchmove", drag);
document.addEventListener("mouseup", endDrag);
document.addEventListener("touchend", endDrag);
} }
} }
update() {
this.currentPage = parseInt(this.context.el.dataset.currentPage);
var el = document.getElementById("slide-preview-" + this.currentPage);
if (el) {
setTimeout(() => {
const slidesLayout = document.getElementById("slides-layout");
const layoutWidth = slidesLayout.clientWidth;
const elementWidth = el.children[0].scrollWidth;
const scrollPosition =
el.children[0].offsetLeft - layoutWidth / 2 + elementWidth / 2;
slidesLayout.scrollTo({
left: scrollPosition,
behavior: "smooth",
});
}, 50);
}
this.initPreview();
}
nextPage() { nextPage() {
if (this.currentPage == this.maxPage - 1) if (this.currentPage == this.maxPage - 1) return;
return;
this.currentPage += 1; this.currentPage += 1;
this.context.pushEventTo(this.context.el, "current-page", { "page": this.currentPage.toString() }); this.context.pushEventTo(this.context.el, "current-page", {
page: this.currentPage.toString(),
});
} }
prevPage() { prevPage() {
if (this.currentPage == 0) if (this.currentPage == 0) return;
return;
this.currentPage -= 1; this.currentPage -= 1;
this.context.pushEventTo(this.context.el, "current-page", { "page": this.currentPage.toString() }); this.context.pushEventTo(this.context.el, "current-page", {
page: this.currentPage.toString(),
});
} }
} }

255
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@sjmc11/tourguidejs": "^0.0.16", "@sjmc11/tourguidejs": "^0.0.16",
"@tailwindcss/container-queries": "^0.1.1",
"air-datepicker": "^3.5.0", "air-datepicker": "^3.5.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"moment": "^2.29.4", "moment": "^2.29.4",
@@ -24,4 +25,4 @@
"split.js": "^1.6.5", "split.js": "^1.6.5",
"tiny-slider": "^2.9.4" "tiny-slider": "^2.9.4"
} }
} }

View File

@@ -1,112 +1,109 @@
const { colors: defaultColors } = require('tailwindcss/defaultTheme') const { colors: defaultColors } = require("tailwindcss/defaultTheme");
const colors = { const colors = {
...defaultColors, ...defaultColors,
...{ ...{
"water-blue": { "water-blue": {
"50": "#E3F2FD", 50: "#E3F2FD",
"100": "#C2E3FA", 100: "#C2E3FA",
"200": "#84C8F6", 200: "#84C8F6",
"300": "#3DA7F0", 300: "#3DA7F0",
"400": "#1395EC", 400: "#1395EC",
"500": "#1186D5", 500: "#1186D5",
"600": "#0D65A1", 600: "#0D65A1",
"700": "#0A5689", 700: "#0A5689",
"800": "#0A4B76", 800: "#0A4B76",
"900": "#073250", 900: "#073250",
}, },
"electric-purple": { "electric-purple": {
"50": "#F2E0FF", 50: "#F2E0FF",
"100": "#E3BDFF", 100: "#E3BDFF",
"200": "#C77AFF", 200: "#C77AFF",
"300": "#A62EFF", 300: "#A62EFF",
"400": "#9200FF", 400: "#9200FF",
"500": "#A327FF", 500: "#A327FF",
"600": "#6400AD", 600: "#6400AD",
"700": "#550094", 700: "#550094",
"800": "#490080", 800: "#490080",
"900": "#320057", 900: "#320057",
}, },
"wedgewood": { wedgewood: {
"50": "#F0F4F8", 50: "#F0F4F8",
"100": "#D9E3ED", 100: "#D9E3ED",
"200": "#B9CCDF", 200: "#B9CCDF",
"300": "#97B3CE", 300: "#97B3CE",
"400": "#7499BE", 400: "#7499BE",
"500": "#507DAA", 500: "#507DAA",
"600": "#3F6388", 600: "#3F6388",
"700": "#314D68", 700: "#314D68",
"800": "#253B50", 800: "#253B50",
"900": "#1A2938", 900: "#1A2938",
}, },
"rose-madder": { "rose-madder": {
"50": "#FCEDEE", 50: "#FCEDEE",
"100": "#F9D5D7", 100: "#F9D5D7",
"200": "#F3ABB0", 200: "#F3ABB0",
"300": "#ED8188", 300: "#ED8188",
"400": "#E75761", 400: "#E75761",
"500": "#E12D39", 500: "#E12D39",
"600": "#B4242E", 600: "#B4242E",
"700": "#871B22", 700: "#871B22",
"800": "#5A1217", 800: "#5A1217",
"900": "#2D090B", 900: "#2D090B",
}, },
"school-bus-yellow": { "school-bus-yellow": {
"50": "#FFFBEB", 50: "#FFFBEB",
"100": "#FEF3C7", 100: "#FEF3C7",
"200": "#FDE68A", 200: "#FDE68A",
"300": "#FCD34D", 300: "#FCD34D",
"400": "#FBBF24", 400: "#FBBF24",
"500": "#F59E0B", 500: "#F59E0B",
"600": "#D97706", 600: "#D97706",
"700": "#B45309", 700: "#B45309",
"800": "#92400E", 800: "#92400E",
"900": "#78350F", 900: "#78350F",
}, },
"green-teal": { "green-teal": {
"50": "#ECFDF5", 50: "#ECFDF5",
"100": "#D1FAE5", 100: "#D1FAE5",
"200": "#A7F3D0", 200: "#A7F3D0",
"300": "#6EE7B7", 300: "#6EE7B7",
"400": "#34D399", 400: "#34D399",
"500": "#10B981", 500: "#10B981",
"600": "#059669", 600: "#059669",
"700": "#047857", 700: "#047857",
"800": "#065F46", 800: "#065F46",
"900": "#064E3B", 900: "#064E3B",
}, },
}, },
} };
module.exports = { module.exports = {
mode: 'jit', mode: "jit",
content: [ content: ["./js/**/*.js", "../lib/*_web/**/*.*ex"],
'./js/**/*.js',
'../lib/*_web/**/*.*ex'
],
safelist: [ safelist: [
'-top-1.5', "-top-1.5",
'top-1', "top-1",
'left-3', "left-3",
'top-6', "top-6",
'h-5', "h-5",
'left-2.5', "left-2.5",
'top-3', "top-3",
'h-7', "h-7",
'bg-secondary-600', "bg-secondary-600",
'text-white', "text-white",
'bg-white', "bg-white",
'text-gray-600' "text-gray-600",
], ],
darkMode: 'media', darkMode: "media",
theme: { theme: {
extend: { extend: {
backgroundSize: { backgroundSize: {
'size-200': '200% 200%', "size-200": "200% 200%",
}, },
backgroundPosition: { backgroundPosition: {
'pos-0': '0% 0%', "pos-0": "0% 0%",
'pos-100': '100% 100%', "pos-100": "100% 100%",
}, },
colors: { colors: {
primary: colors["water-blue"], primary: colors["water-blue"],
@@ -114,24 +111,24 @@ module.exports = {
neutral: colors["wedgewood"], neutral: colors["wedgewood"],
"supporting-red": colors["rose-madder"], "supporting-red": colors["rose-madder"],
"supporting-yellow": colors["school-bus-yellow"], "supporting-yellow": colors["school-bus-yellow"],
"supporting-green": colors["green-teal"] "supporting-green": colors["green-teal"],
} },
}, },
fontFamily: { fontFamily: {
sans: ['Roboto', 'sans-serif'], sans: ["Roboto", "sans-serif"],
serif: ['Merriweather', 'serif'], serif: ["Merriweather", "serif"],
}, },
boxShadow: { boxShadow: {
"base": "0px 1px 3px 0px rgba(0,0,0,0.1), 0px 1px 2px 0px rgba(0,0,0,0.06)", base: "0px 1px 3px 0px rgba(0,0,0,0.1), 0px 1px 2px 0px rgba(0,0,0,0.06)",
"lg": "0px 4px 6px 0px rgba(0,0,0,0.05), 0px 10px 15px 0px rgba(0,0,0,0.1)", lg: "0px 4px 6px 0px rgba(0,0,0,0.05), 0px 10px 15px 0px rgba(0,0,0,0.1)",
"md": "0px 4px 6px 0px rgba(0,0,0,0.1), 0px 2px 4px 0px rgba(0,0,0,0.06)", md: "0px 4px 6px 0px rgba(0,0,0,0.1), 0px 2px 4px 0px rgba(0,0,0,0.06)",
"xl": "0px 10px 10px 0px rgba(0,0,0,0.04), 0px 20px 25px 0px rgba(0,0,0,0.1)", xl: "0px 10px 10px 0px rgba(0,0,0,0.04), 0px 20px 25px 0px rgba(0,0,0,0.1)",
"2xl": "0px 25px 50px 0px rgba(0,0,0,0.25)", "2xl": "0px 25px 50px 0px rgba(0,0,0,0.25)",
"inner": "inset 0px 2px 4px 0px rgba(0,0,0,0.06)" inner: "inset 0px 2px 4px 0px rgba(0,0,0,0.06)",
} },
}, },
variants: { variants: {
extend: {}, extend: {},
}, },
plugins: [], plugins: [require("@tailwindcss/container-queries")],
} };

View File

@@ -41,7 +41,6 @@ case secret_key_base do
nil nil
end end
site_url = get_var_from_path_or_env(config_dir, "SITE_URL", "http://localhost:4000")
base_url = get_var_from_path_or_env(config_dir, "BASE_URL") base_url = get_var_from_path_or_env(config_dir, "BASE_URL")
if !base_url do if !base_url do
@@ -82,6 +81,50 @@ same_site_cookie = get_var_from_path_or_env(config_dir, "SAME_SITE_COOKIE", "Lax
secure_cookie = secure_cookie =
get_var_from_path_or_env(config_dir, "SECURE_COOKIE", "false") |> String.to_existing_atom() get_var_from_path_or_env(config_dir, "SECURE_COOKIE", "false") |> String.to_existing_atom()
oidc_issuer = get_var_from_path_or_env(config_dir, "OIDC_ISSUER", "https://accounts.google.com")
oidc_client_id = get_var_from_path_or_env(config_dir, "OIDC_CLIENT_ID", nil)
oidc_client_secret = get_var_from_path_or_env(config_dir, "OIDC_CLIENT_SECRET", nil)
oidc_scopes = get_var_from_path_or_env(config_dir, "OIDC_SCOPES", "openid email profile")
oidc_provider_name = get_var_from_path_or_env(config_dir, "OIDC_PROVIDER_NAME", "OpenID Connect")
oidc_logo_url = get_var_from_path_or_env(config_dir, "OIDC_LOGO_URL", "/images/icons/openid.png")
oidc_auto_redirect_login =
get_var_from_path_or_env(config_dir, "OIDC_AUTO_REDIRECT_LOGIN", "false")
|> String.to_existing_atom()
oidc_property_mappings =
get_var_from_path_or_env(config_dir, "OIDC_PROPERTY_MAPPINGS", nil)
|> case do
nil ->
nil
mappings ->
String.split(mappings, ",")
|> Enum.map(&String.split(&1, ":"))
|> Enum.into(%{}, fn [key, value] -> {key, value} end)
end
oidc_enabled =
!is_nil(oidc_client_id) and !is_nil(oidc_client_secret)
allow_unlink_external_provider =
get_var_from_path_or_env(config_dir, "ALLOW_UNLINK_EXTERNAL_PROVIDER", "true")
|> String.to_existing_atom()
logout_redirect_url = get_var_from_path_or_env(config_dir, "LOGOUT_REDIRECT_URL", nil)
config :claper, :oidc,
enabled: oidc_enabled,
issuer: oidc_issuer,
client_id: oidc_client_id,
client_secret: oidc_client_secret,
scopes: String.split(oidc_scopes, " "),
provider_name: oidc_provider_name,
logo_url: oidc_logo_url,
property_mappings: oidc_property_mappings,
auto_redirect_login: oidc_auto_redirect_login
config :claper, Claper.Repo, config :claper, Claper.Repo,
url: database_url, url: database_url,
ssl: db_ssl, ssl: db_ssl,
@@ -94,6 +137,7 @@ config :claper, Claper.Repo,
config :claper, ClaperWeb.Endpoint, config :claper, ClaperWeb.Endpoint,
url: [scheme: base_url.scheme, host: base_url.host, path: base_url.path, port: base_url.port], url: [scheme: base_url.scheme, host: base_url.host, path: base_url.path, port: base_url.port],
base_url: base_url,
http: [ http: [
ip: listen_ip, ip: listen_ip,
port: port, port: port,
@@ -105,7 +149,9 @@ config :claper, ClaperWeb.Endpoint,
secure_cookie: secure_cookie secure_cookie: secure_cookie
config :claper, config :claper,
enable_account_creation: enable_account_creation enable_account_creation: enable_account_creation,
allow_unlink_external_provider: allow_unlink_external_provider,
logout_redirect_url: logout_redirect_url
config :claper, :presentations, config :claper, :presentations,
max_file_size: max_file_size, max_file_size: max_file_size,

10
dev.sh
View File

@@ -1,10 +1,12 @@
export $(cat .env | xargs) set -a
source .env
set +a
args=("$@") args=("$@")
if [ "${args[0]}" == "test" ]; then if [ "${args[0]}" == "start" ]; then
mix test
elif [ "${args[0]}" == "start" ]; then
mix phx.server mix phx.server
else
mix "$@"
fi fi

View File

@@ -4,11 +4,26 @@ defmodule Claper.Accounts do
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Claper.Accounts
alias Claper.Repo alias Claper.Repo
alias Claper.Accounts.{User, UserToken, UserNotifier} alias Claper.Accounts.{User, UserToken, UserNotifier}
## Database getters @doc """
Creates a user.
## Examples
iex> create_user(%{field: value})
{:ok, %User{}}
iex> create_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert(returning: [:uuid])
end
@doc """ @doc """
Gets a user by email. Gets a user by email.
@@ -26,6 +41,30 @@ defmodule Claper.Accounts do
Repo.get_by(User, email: email) Repo.get_by(User, email: email)
end end
@doc """
Gets a user by email and creates a new user if the user does not exist.
## Examples
iex> get_user_by_email_or_create("foo@example.com")
%User{}
iex> get_user_by_email_or_create("unknown@example.com")
%User{}
"""
def get_user_by_email_or_create(email) when is_binary(email) do
case get_user_by_email(email) do
nil ->
create_user(%{
email: email,
confirmed_at: DateTime.utc_now(),
is_randomized_password: true,
password: :crypto.strong_rand_bytes(32)
})
user ->
{:ok, user}
end
end
@doc """ @doc """
Gets a user by email and password. Gets a user by email and password.
@@ -190,6 +229,24 @@ defmodule Claper.Accounts do
end end
end end
@doc """
Sets the user password.
## Examples
iex> set_user_password(user, %{password: ...})
{:ok, %User{}}
iex> set_user_password(user, %{password: ...})
{:error, %Ecto.Changeset{}}
"""
def set_user_password(user, attrs) do
user
|> User.password_changeset(attrs |> Map.put("is_randomized_password", false))
|> Repo.update()
|> case do
{:ok, user} -> {:ok, user}
{:error, changeset} -> {:error, changeset}
end
end
@doc """ @doc """
Updates the user preferences. Updates the user preferences.
## Examples ## Examples
@@ -354,7 +411,11 @@ defmodule Claper.Accounts do
when is_function(reset_password_url_fun, 1) do when is_function(reset_password_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
Repo.insert!(user_token) Repo.insert!(user_token)
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
UserNotifier.deliver_reset_password_instructions(
user,
reset_password_url_fun.(encoded_token)
)
end end
@doc """ @doc """
@@ -454,4 +515,59 @@ defmodule Claper.Accounts do
def delete(user) do def delete(user) do
Repo.delete(user) Repo.delete(user)
end end
## OIDC
def create_oidc_user(attrs) do
%Accounts.Oidc.User{}
|> Accounts.Oidc.User.changeset(attrs)
|> Repo.insert()
end
def remove_oidc_user(claper_user, issuer) do
Repo.delete_all(
from u in Accounts.Oidc.User,
where: u.issuer == ^issuer and u.user_id == ^claper_user.id
)
end
def get_all_oidc_users_by_email(email) do
Repo.all(from u in Accounts.Oidc.User, where: u.email == ^email)
end
def get_oidc_user_by_sub(sub) do
Repo.get_by(Accounts.Oidc.User, sub: sub)
end
def get_or_create_user_with_oidc(
%{
sub: sub
} = attrs
) do
case get_oidc_user_by_sub(sub) do
nil -> create_new_user(attrs)
%Accounts.Oidc.User{} = user -> update_oidc_user(user, attrs)
end
end
defp create_new_user(attrs) do
with {:ok, claper_user} <- get_user_by_email_or_create(attrs.email),
updated_attrs <-
Map.merge(attrs, %{user_id: claper_user.id}),
{:ok, user} <- create_oidc_user(updated_attrs) do
{:ok, user |> Repo.preload(:user)}
else
_ -> {:error, %{reason: :invalid_user, msg: "Invalid Claper user"}}
end
end
defp update_oidc_user(user, attrs) do
user
|> Accounts.Oidc.User.changeset(attrs)
|> Repo.update()
|> case do
{:ok, user} -> {:ok, user |> Repo.preload(:user)}
{:error, changeset} -> {:error, changeset}
end
end
end end

View File

@@ -0,0 +1,66 @@
defmodule Claper.Accounts.Oidc.User do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
sub: String.t(),
name: String.t() | nil,
email: String.t(),
issuer: String.t(),
provider: String.t(),
refresh_token: String.t(),
access_token: String.t(),
expires_at: NaiveDateTime.t(),
photo_url: String.t(),
groups: {:array, :string},
roles: :string,
organization: String.t(),
user_id: integer(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "oidc_users" do
field :sub, :string
field :name, :string
field :email, :string
field :issuer, :string
field :provider, :string
field :id_token, :string
field :refresh_token, :string, redact: true
field :access_token, :string, redact: true
field :expires_at, :naive_datetime
field :photo_url, :string
field :groups, {:array, :string}
field :roles, :string
field :organization, :string
belongs_to :user, Claper.Accounts.User
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [
:sub,
:name,
:email,
:issuer,
:provider,
:id_token,
:photo_url,
:access_token,
:expires_at,
:groups,
:organization,
:user_id,
:roles,
:refresh_token
])
|> validate_required([:sub, :email, :issuer, :provider, :id_token, :user_id])
|> unique_constraint(:sub)
end
end

View File

@@ -3,12 +3,26 @@ defmodule Claper.Accounts.User do
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
uuid: Ecto.UUID.t(),
email: String.t(),
password: String.t() | nil,
hashed_password: String.t(),
is_randomized_password: boolean(),
confirmed_at: NaiveDateTime.t() | nil,
locale: String.t() | nil,
events: [Claper.Events.Event.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "users" do schema "users" do
field :uuid, :binary_id field :uuid, :binary_id
field :email, :string field :email, :string
field :password, :string, virtual: true, redact: true field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true field :hashed_password, :string, redact: true
field :is_admin, :boolean field :is_randomized_password, :boolean
field :confirmed_at, :naive_datetime field :confirmed_at, :naive_datetime
field :locale, :string field :locale, :string
@@ -19,7 +33,7 @@ defmodule Claper.Accounts.User do
def registration_changeset(user, attrs, opts \\ []) do def registration_changeset(user, attrs, opts \\ []) do
user user
|> cast(attrs, [:email, :confirmed_at, :password]) |> cast(attrs, [:email, :confirmed_at, :password, :is_randomized_password])
|> validate_email() |> validate_email()
|> validate_password(opts) |> validate_password(opts)
end end
@@ -78,7 +92,7 @@ defmodule Claper.Accounts.User do
""" """
def password_changeset(user, attrs, opts \\ []) do def password_changeset(user, attrs, opts \\ []) do
user user
|> cast(attrs, [:password]) |> cast(attrs, [:password, :is_randomized_password])
|> validate_confirmation(:password) |> validate_confirmation(:password)
|> validate_password(opts) |> validate_password(opts)
end end

View File

@@ -8,6 +8,7 @@ defmodule Claper.Application do
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
topologies = Application.get_env(:libcluster, :topologies) || [] topologies = Application.get_env(:libcluster, :topologies) || []
oidc_config = Application.get_env(:claper, :oidc) || []
children = [ children = [
{Cluster.Supervisor, [topologies, [name: Claper.ClusterSupervisor]]}, {Cluster.Supervisor, [topologies, [name: Claper.ClusterSupervisor]]},
@@ -23,7 +24,9 @@ defmodule Claper.Application do
# Start a worker by calling: Claper.Worker.start_link(arg) # Start a worker by calling: Claper.Worker.start_link(arg)
# {Claper.Worker, arg} # {Claper.Worker, arg}
{Finch, name: Swoosh.Finch}, {Finch, name: Swoosh.Finch},
{Task.Supervisor, name: Claper.TaskSupervisor} {Task.Supervisor, name: Claper.TaskSupervisor},
{Oidcc.ProviderConfiguration.Worker,
%{issuer: oidc_config[:issuer], name: Claper.OidcProviderConfig}}
] ]
# See https://hexdocs.pm/elixir/Supervisor.html # See https://hexdocs.pm/elixir/Supervisor.html

View File

@@ -93,6 +93,14 @@ defmodule Claper.Embeds do
%Embed{} %Embed{}
|> Embed.changeset(attrs) |> Embed.changeset(attrs)
|> Repo.insert() |> Repo.insert()
|> case do
{:ok, embed} ->
embed = Repo.preload(embed, presentation_file: :event)
broadcast({:ok, embed, embed.presentation_file.event.uuid}, :embed_created)
{:error, changeset} ->
{:error, %{changeset | action: :insert}}
end
end end
@doc """ @doc """
@@ -157,22 +165,16 @@ defmodule Claper.Embeds do
|> Repo.update_all(set: [enabled: false]) |> Repo.update_all(set: [enabled: false])
end end
def set_status(id, presentation_file_id, position, status) do def set_enabled(id) do
if status do get_embed!(id)
from(e in Embed, |> Ecto.Changeset.change(enabled: true)
where: |> Repo.update()
e.presentation_file_id == ^presentation_file_id and e.position == ^position and end
e.id != ^id
)
|> Repo.update_all(set: [enabled: false])
end
from(e in Embed, def set_disabled(id) do
where: get_embed!(id)
e.presentation_file_id == ^presentation_file_id and e.position == ^position and |> Ecto.Changeset.change(enabled: false)
e.id == ^id |> Repo.update()
)
|> Repo.update_all(set: [enabled: status])
end end
defp broadcast({:error, _reason} = error, _embed), do: error defp broadcast({:error, _reason} = error, _embed), do: error

View File

@@ -3,10 +3,24 @@ defmodule Claper.Embeds.Embed do
import Ecto.Changeset import Ecto.Changeset
import ClaperWeb.Gettext import ClaperWeb.Gettext
@type t :: %__MODULE__{
id: integer(),
title: String.t(),
content: String.t(),
provider: String.t(),
enabled: boolean(),
position: integer() | nil,
attendee_visibility: boolean() | nil,
presentation_file_id: integer() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@derive {Jason.Encoder, only: [:title, :content, :position, :attendee_visibility]} @derive {Jason.Encoder, only: [:title, :content, :position, :attendee_visibility]}
schema "embeds" do schema "embeds" do
field :title, :string field :title, :string
field :content, :string field :content, :string
field :provider, :string
field :enabled, :boolean, default: true field :enabled, :boolean, default: true
field :position, :integer, default: 0 field :position, :integer, default: 0
field :attendee_visibility, :boolean, default: false field :attendee_visibility, :boolean, default: false
@@ -23,6 +37,7 @@ defmodule Claper.Embeds.Embed do
:enabled, :enabled,
:title, :title,
:content, :content,
:provider,
:presentation_file_id, :presentation_file_id,
:position, :position,
:attendee_visibility :attendee_visibility
@@ -30,12 +45,61 @@ defmodule Claper.Embeds.Embed do
|> validate_required([ |> validate_required([
:title, :title,
:content, :content,
:provider,
:presentation_file_id, :presentation_file_id,
:position, :position,
:attendee_visibility :attendee_visibility
]) ])
|> validate_format(:content, ~r/<iframe.*<\/iframe>/, |> validate_inclusion(:provider, ["youtube", "vimeo", "canva", "googleslides", "custom"])
message: gettext("Invalid embed format (should start with <iframe> and end with </iframe>)") |> validate_provider_url()
) end
defp validate_provider_url(changeset) do
case get_field(changeset, :provider) do
"youtube" ->
changeset
|> validate_format(:content, ~r/^https?:\/\/.+$/,
message: gettext("Please enter a valid link starting with http:// or https://")
)
|> validate_format(:content, ~r/youtu\.be/,
message: gettext("Please enter a valid %{provider} link", provider: "YouTube")
)
"canva" ->
changeset
|> validate_format(:content, ~r/^https?:\/\/.+$/,
message: gettext("Please enter a valid link starting with http:// or https://")
)
|> validate_format(:content, ~r/canva\.com/,
message: gettext("Please enter a valid %{provider} link", provider: "Canva")
)
"googleslides" ->
changeset
|> validate_format(:content, ~r/^https?:\/\/.+$/,
message: gettext("Please enter a valid link starting with http:// or https://")
)
|> validate_format(:content, ~r/google\.com/,
message: gettext("Please enter a valid %{provider} link", provider: "Google Slides")
)
"vimeo" ->
changeset
|> validate_format(:content, ~r/^https?:\/\/.+$/,
message: gettext("Please enter a valid link starting with http:// or https://")
)
|> validate_format(:content, ~r/vimeo\.com/,
message: gettext("Please enter a valid %{provider} link", provider: "Vimeo")
)
"custom" ->
changeset
|> validate_format(:content, ~r/<iframe.*?<\/iframe>/s,
message: gettext("Please enter valid HTML content with an iframe tag")
)
_ ->
changeset
end
end end
end end

View File

@@ -97,7 +97,7 @@ defmodule Claper.Events do
event = Repo.get_by!(Event, uuid: id) event = Repo.get_by!(Event, uuid: id)
is_leader = is_leader =
Claper.Events.is_leaded_by(current_user.email, event) || event.user_id == current_user.id Claper.Events.leaded_by?(current_user.email, event) || event.user_id == current_user.id
if is_leader do if is_leader do
event |> Repo.preload(preload) event |> Repo.preload(preload)
@@ -177,12 +177,12 @@ defmodule Claper.Events do
## Examples ## Examples
iex> is_leaded_by("email@example.com", 123) iex> leaded_by?("email@example.com", 123)
true true
""" """
def is_leaded_by(email, event) do def leaded_by?(email, event) do
from(a in ActivityLeader, from(a in ActivityLeader,
join: u in Claper.Accounts.User, join: u in Claper.Accounts.User,
on: u.email == a.email, on: u.email == a.email,
@@ -365,6 +365,138 @@ defmodule Claper.Events do
end end
end end
@doc """
Duplicate an event
## Examples
iex> duplicate(user_id, event_uuid)
{:ok, %Event{}}
iex> duplicate(user_id, event_uuid)
{:error, %Ecto.Changeset{}}
"""
def duplicate_event(user_id, event_uuid) do
case Ecto.Multi.new()
|> Ecto.Multi.run(:original_event, fn _repo, _changes ->
{:ok,
get_user_event!(user_id, event_uuid,
presentation_file: [
polls: [:poll_opts],
forms: [],
embeds: [],
presentation_state: []
]
)}
end)
|> Ecto.Multi.run(:new_event, fn _repo, %{original_event: original_event} ->
new_code =
for _ <- 1..5,
into: "",
do: <<Enum.random(~c"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")>>
attrs =
Map.from_struct(original_event)
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_file])
|> Map.put(:leaders, [])
|> Map.put(:code, "#{new_code}")
|> Map.put(:name, "#{original_event.name} (Copy)")
create_event(attrs)
end)
|> Ecto.Multi.run(:new_presentation_file, fn _repo,
%{
original_event: original_event,
new_event: new_event
} ->
attrs =
Map.from_struct(original_event.presentation_file)
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_state])
|> Map.put(:event_id, new_event.id)
Claper.Presentations.create_presentation_file(attrs)
end)
|> Ecto.Multi.run(:new_presentation_state, fn _repo,
%{
original_event: original_event,
new_presentation_file:
new_presentation_file
} ->
attrs =
Map.from_struct(original_event.presentation_file.presentation_state)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
|> Map.put(:position, 0)
|> Map.put(:banned, nil)
Claper.Presentations.create_presentation_state(attrs)
end)
|> Ecto.Multi.run(:polls, fn _repo,
%{
new_presentation_file: new_presentation_file,
original_event: original_event
} ->
{:ok,
Enum.map(original_event.presentation_file.polls, fn poll ->
poll_attrs =
Map.from_struct(poll)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
|> Map.put(
:poll_opts,
Enum.map(poll.poll_opts, fn opt ->
Map.from_struct(opt)
|> Map.drop([:id, :inserted_at, :updated_at])
end)
)
{:ok, new_poll} = Claper.Polls.create_poll(poll_attrs)
new_poll
end)}
end)
|> Ecto.Multi.run(:forms, fn _repo,
%{
new_presentation_file: new_presentation_file,
original_event: original_event
} ->
{:ok,
Enum.map(original_event.presentation_file.forms, fn form ->
form_attrs =
Map.from_struct(form)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
|> Map.put(
:fields,
Enum.map(form.fields, &Map.from_struct(&1))
)
{:ok, new_form} = Claper.Forms.create_form(form_attrs)
new_form
end)}
end)
|> Ecto.Multi.run(:embeds, fn _repo,
%{
new_presentation_file: new_presentation_file,
original_event: original_event
} ->
{:ok,
Enum.map(original_event.presentation_file.embeds, fn embed ->
embed_attrs =
Map.from_struct(embed)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
{:ok, new_embed} = Claper.Embeds.create_embed(embed_attrs)
new_embed
end)}
end)
|> Repo.transaction() do
{:ok, %{new_event: new_event}} -> {:ok, new_event}
{:error, _failed_operation, failed_value, _changes_so_far} -> {:error, failed_value}
end
end
@doc """ @doc """
Deletes a event. Deletes a event.
@@ -396,6 +528,24 @@ defmodule Claper.Events do
alias Claper.Events.ActivityLeader alias Claper.Events.ActivityLeader
@doc """
Creates a activity leader.
## Examples
iex> create_activity_leader(%{field: value})
{:ok, %ActivityLeader{}}
iex> create_activity_leader(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_activity_leader(attrs) do
%ActivityLeader{}
|> ActivityLeader.changeset(attrs)
|> Repo.insert()
end
@doc """ @doc """
Gets a single facilitator. Gets a single facilitator.

View File

@@ -2,6 +2,18 @@ defmodule Claper.Events.ActivityLeader do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
temp_id: String.t() | nil,
delete: boolean() | nil,
user_id: integer() | nil,
user_email: String.t() | nil,
email: String.t(),
event_id: integer(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "activity_leaders" do schema "activity_leaders" do
field :temp_id, :string, virtual: true field :temp_id, :string, virtual: true
field :delete, :boolean, virtual: true field :delete, :boolean, virtual: true

View File

@@ -2,6 +2,22 @@ defmodule Claper.Events.Event do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
uuid: Ecto.UUID.t(),
name: String.t() | nil,
code: String.t(),
audience_peak: integer() | nil,
started_at: NaiveDateTime.t(),
expired_at: NaiveDateTime.t() | nil,
posts: [Claper.Posts.Post.t()] | nil,
leaders: [Claper.Events.ActivityLeader.t()] | nil,
presentation_file: Claper.Presentations.PresentationFile.t() | nil,
user: Claper.Accounts.User.t() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "events" do schema "events" do
field :uuid, :binary_id field :uuid, :binary_id
field :name, :string field :name, :string

View File

@@ -95,6 +95,14 @@ defmodule Claper.Forms do
%Form{} %Form{}
|> Form.changeset(attrs) |> Form.changeset(attrs)
|> Repo.insert() |> Repo.insert()
|> case do
{:ok, form} ->
form = Repo.preload(form, presentation_file: :event)
broadcast({:ok, form, form.presentation_file.event.uuid}, :form_created)
{:error, changeset} ->
{:error, %{changeset | action: :insert}}
end
end end
@doc """ @doc """
@@ -181,22 +189,16 @@ defmodule Claper.Forms do
|> Repo.update_all(set: [enabled: false]) |> Repo.update_all(set: [enabled: false])
end end
def set_status(id, presentation_file_id, position, status) do def set_enabled(id) do
if status do get_form!(id)
from(f in Form, |> Ecto.Changeset.change(enabled: true)
where: |> Repo.update()
f.presentation_file_id == ^presentation_file_id and f.position == ^position and end
f.id != ^id
)
|> Repo.update_all(set: [enabled: false])
end
from(f in Form, def set_disabled(id) do
where: get_form!(id)
f.presentation_file_id == ^presentation_file_id and f.position == ^position and |> Ecto.Changeset.change(enabled: false)
f.id == ^id |> Repo.update()
)
|> Repo.update_all(set: [enabled: status])
end end
defp broadcast({:error, _reason} = error, _form), do: error defp broadcast({:error, _reason} = error, _form), do: error

View File

@@ -2,6 +2,11 @@ defmodule Claper.Forms.Field do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
name: String.t(),
type: String.t()
}
@primary_key false @primary_key false
embedded_schema do embedded_schema do
field :name, :string field :name, :string

View File

@@ -2,6 +2,18 @@ defmodule Claper.Forms.Form do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
enabled: boolean() | nil,
position: integer() | nil,
title: String.t(),
fields: [Claper.Forms.Field.t()] | nil,
presentation_file_id: integer() | nil,
form_submits: [Claper.Forms.FormSubmit.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@derive {Jason.Encoder, only: [:title, :position]} @derive {Jason.Encoder, only: [:title, :position]}
schema "forms" do schema "forms" do
field :enabled, :boolean, default: true field :enabled, :boolean, default: true

View File

@@ -2,6 +2,16 @@ defmodule Claper.Forms.FormSubmit do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
attendee_identifier: String.t() | nil,
response: map(),
form_id: integer() | nil,
user_id: integer() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "form_submits" do schema "form_submits" do
field :attendee_identifier, :string field :attendee_identifier, :string
field :response, :map, on_replace: :delete field :response, :map, on_replace: :delete

115
lib/claper/interactions.ex Normal file
View File

@@ -0,0 +1,115 @@
defmodule Claper.Interactions do
alias Claper.Polls
alias Claper.Forms
alias Claper.Embeds
alias Claper.Events
alias Claper.Presentations
import Ecto.Query, warn: false
@type interaction :: Polls.Poll | Forms.Form | Embeds.Embed
def get_number_total_interactions(presentation_file_id) do
from(p in Polls.Poll,
where: p.presentation_file_id == ^presentation_file_id,
select: count(p.id)
)
|> Claper.Repo.one()
|> Kernel.+(
from(f in Forms.Form,
where: f.presentation_file_id == ^presentation_file_id,
select: count(f.id)
)
|> Claper.Repo.one()
)
|> Kernel.+(
from(e in Embeds.Embed,
where: e.presentation_file_id == ^presentation_file_id,
select: count(e.id)
)
|> Claper.Repo.one()
)
end
def get_active_interaction(event, position) do
with {:ok, interactions} <- get_interactions_at_position(event, position) do
interactions |> Enum.filter(&(&1.enabled == true)) |> List.first()
end
end
def get_interactions_at_position(
%Events.Event{
presentation_file: %Presentations.PresentationFile{id: presentation_file_id}
} = event,
position,
broadcast \\ false
) do
with polls <- Polls.list_polls_at_position(presentation_file_id, position),
forms <- Forms.list_forms_at_position(presentation_file_id, position),
embeds <- Embeds.list_embeds_at_position(presentation_file_id, position) do
interactions =
(polls ++ forms ++ embeds)
|> Enum.sort_by(& &1.inserted_at, {:asc, NaiveDateTime})
if broadcast do
active_interaction = interactions |> Enum.filter(&(&1.enabled == true)) |> List.first()
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{event.uuid}",
{:current_interaction, active_interaction}
)
end
{:ok, interactions}
end
end
def enable_interaction(interaction) do
Ecto.Multi.new()
|> Ecto.Multi.run(:disable_polls, fn _repo, _ ->
{count, _} = Polls.disable_all(interaction.presentation_file_id, interaction.position)
{:ok, count}
end)
|> Ecto.Multi.run(:disable_forms, fn _repo, _ ->
{count, _} = Forms.disable_all(interaction.presentation_file_id, interaction.position)
{:ok, count}
end)
|> Ecto.Multi.run(:disable_embeds, fn _repo, _ ->
{count, _} = Embeds.disable_all(interaction.presentation_file_id, interaction.position)
{:ok, count}
end)
|> Ecto.Multi.run(:enable_interaction, fn _repo, _ ->
set_enabled(interaction)
end)
|> Claper.Repo.transaction()
|> case do
{:ok, _} -> :ok
{:error, _, reason, _} -> {:error, reason}
end
end
defp set_enabled(%Polls.Poll{} = interaction) do
Polls.set_enabled(interaction.id)
end
defp set_enabled(%Forms.Form{} = interaction) do
Forms.set_enabled(interaction.id)
end
defp set_enabled(%Embeds.Embed{} = interaction) do
Embeds.set_enabled(interaction.id)
end
def disable_interaction(%Polls.Poll{} = interaction) do
Polls.set_disabled(interaction.id)
end
def disable_interaction(%Forms.Form{} = interaction) do
Forms.set_disabled(interaction.id)
end
def disable_interaction(%Embeds.Embed{} = interaction) do
Embeds.set_disabled(interaction.id)
end
end

View File

@@ -142,6 +142,14 @@ defmodule Claper.Polls do
%Poll{} %Poll{}
|> Poll.changeset(attrs) |> Poll.changeset(attrs)
|> Repo.insert() |> Repo.insert()
|> case do
{:ok, poll} ->
poll = Repo.preload(poll, presentation_file: :event)
broadcast({:ok, poll, poll.presentation_file.event.uuid}, :poll_created)
{:error, changeset} ->
{:error, %{changeset | action: :insert}}
end
end end
@doc """ @doc """
@@ -275,22 +283,16 @@ defmodule Claper.Polls do
|> Repo.update_all(set: [enabled: false]) |> Repo.update_all(set: [enabled: false])
end end
def set_status(id, presentation_file_id, position, status) do def set_enabled(id) do
if status do get_poll!(id)
from(p in Poll, |> Ecto.Changeset.change(enabled: true)
where: |> Repo.update()
p.presentation_file_id == ^presentation_file_id and p.position == ^position and end
p.id != ^id
)
|> Repo.update_all(set: [enabled: false])
end
from(p in Poll, def set_disabled(id) do
where: get_poll!(id)
p.presentation_file_id == ^presentation_file_id and p.position == ^position and |> Ecto.Changeset.change(enabled: false)
p.id == ^id |> Repo.update()
)
|> Repo.update_all(set: [enabled: status])
end end
defp broadcast({:error, _reason} = error, _poll), do: error defp broadcast({:error, _reason} = error, _poll), do: error

View File

@@ -2,6 +2,21 @@ defmodule Claper.Polls.Poll do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
title: String.t(),
position: integer() | nil,
total: integer() | nil,
enabled: boolean() | nil,
multiple: boolean() | nil,
presentation_file_id: integer() | nil,
poll_opts: [Claper.Polls.PollOpt.t()],
poll_votes: [Claper.Polls.PollVote.t()] | nil,
show_results: boolean() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@derive {Jason.Encoder, only: [:title, :position]} @derive {Jason.Encoder, only: [:title, :position]}
schema "polls" do schema "polls" do
field :title, :string field :title, :string
@@ -9,6 +24,7 @@ defmodule Claper.Polls.Poll do
field :total, :integer, virtual: true field :total, :integer, virtual: true
field :enabled, :boolean field :enabled, :boolean
field :multiple, :boolean field :multiple, :boolean
field :show_results, :boolean
belongs_to :presentation_file, Claper.Presentations.PresentationFile belongs_to :presentation_file, Claper.Presentations.PresentationFile
has_many :poll_opts, Claper.Polls.PollOpt, on_replace: :delete has_many :poll_opts, Claper.Polls.PollOpt, on_replace: :delete
@@ -20,8 +36,17 @@ defmodule Claper.Polls.Poll do
@doc false @doc false
def changeset(poll, attrs) do def changeset(poll, attrs) do
poll poll
|> cast(attrs, [:title, :presentation_file_id, :position, :enabled, :total, :multiple]) |> cast(attrs, [
:title,
:presentation_file_id,
:position,
:enabled,
:total,
:multiple,
:show_results
])
|> cast_assoc(:poll_opts, required: true) |> cast_assoc(:poll_opts, required: true)
|> validate_required([:title, :presentation_file_id, :position]) |> validate_required([:title, :presentation_file_id, :position])
|> validate_length(:title, max: 255)
end end
end end

View File

@@ -2,6 +2,18 @@ defmodule Claper.Polls.PollOpt do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
content: String.t(),
vote_count: integer(),
percentage: float(),
poll_id: integer(),
poll: Claper.Polls.Poll.t(),
poll_votes: [Claper.Polls.PollVote.t()],
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@derive {Jason.Encoder, only: [:content, :vote_count]} @derive {Jason.Encoder, only: [:content, :vote_count]}
schema "poll_opts" do schema "poll_opts" do
field :content, :string field :content, :string
@@ -19,5 +31,6 @@ defmodule Claper.Polls.PollOpt do
poll_opt poll_opt
|> cast(attrs, [:content, :vote_count, :poll_id]) |> cast(attrs, [:content, :vote_count, :poll_id])
|> validate_required([:content]) |> validate_required([:content])
|> validate_length(:content, max: 255)
end end
end end

View File

@@ -2,6 +2,16 @@ defmodule Claper.Polls.PollVote do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
attendee_identifier: String.t() | nil,
poll_id: integer() | nil,
poll_opt_id: integer() | nil,
user_id: integer() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "poll_votes" do schema "poll_votes" do
field :attendee_identifier, :string field :attendee_identifier, :string

View File

@@ -2,6 +2,24 @@ defmodule Claper.Posts.Post do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
body: String.t(),
uuid: Ecto.UUID.t(),
like_count: integer() | nil,
love_count: integer() | nil,
lol_count: integer() | nil,
name: String.t() | nil,
attendee_identifier: String.t() | nil,
position: integer() | nil,
pinned: boolean() | nil,
event_id: integer() | nil,
user_id: integer() | nil,
reactions: [Claper.Posts.Reaction.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "posts" do schema "posts" do
field :body, :string field :body, :string
field :uuid, :binary_id field :uuid, :binary_id

View File

@@ -2,6 +2,16 @@ defmodule Claper.Posts.Reaction do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
icon: String.t() | nil,
attendee_identifier: String.t() | nil,
post_id: integer() | nil,
user_id: integer() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "reactions" do schema "reactions" do
field :icon, :string field :icon, :string
field :attendee_identifier, :string field :attendee_identifier, :string

View File

@@ -25,8 +25,11 @@ defmodule Claper.Presentations do
def get_presentation_file!(id, preload \\ []), def get_presentation_file!(id, preload \\ []),
do: Repo.get!(PresentationFile, id) |> Repo.preload(preload) do: Repo.get!(PresentationFile, id) |> Repo.preload(preload)
def get_presentation_file_by_hash!(hash) when is_binary(hash), def get_presentation_files_by_hash(hash) when is_binary(hash),
do: Repo.get_by(PresentationFile, hash: hash) |> Repo.preload([:event]) do: Repo.all(from p in PresentationFile, where: p.hash == ^hash)
def get_presentation_files_by_hash(hash) when is_nil(hash),
do: []
@doc """ @doc """
Creates a presentation_files. Creates a presentation_files.

View File

@@ -2,6 +2,20 @@ defmodule Claper.Presentations.PresentationFile do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
hash: String.t() | nil,
length: integer() | nil,
status: String.t() | nil,
event_id: integer() | nil,
polls: [Claper.Polls.Poll.t()] | nil,
forms: [Claper.Forms.Form.t()] | nil,
embeds: [Claper.Embeds.Embed.t()] | nil,
presentation_state: Claper.Presentations.PresentationState.t(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "presentation_files" do schema "presentation_files" do
field :hash, :string field :hash, :string
field :length, :integer field :length, :integer

View File

@@ -2,6 +2,22 @@ defmodule Claper.Presentations.PresentationState do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
position: integer() | nil,
chat_visible: boolean() | nil,
poll_visible: boolean() | nil,
join_screen_visible: boolean() | nil,
chat_enabled: boolean() | nil,
anonymous_chat_enabled: boolean() | nil,
message_reaction_enabled: boolean() | nil,
banned: [String.t()] | nil,
show_only_pinned: boolean() | nil,
presentation_file_id: integer() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "presentation_states" do schema "presentation_states" do
field :position, :integer field :position, :integer
field :chat_visible, :boolean field :chat_visible, :boolean
@@ -10,7 +26,6 @@ defmodule Claper.Presentations.PresentationState do
field :chat_enabled, :boolean field :chat_enabled, :boolean
field :anonymous_chat_enabled, :boolean field :anonymous_chat_enabled, :boolean
field :message_reaction_enabled, :boolean, default: true field :message_reaction_enabled, :boolean, default: true
field :show_poll_results_enabled, :boolean, default: true
field :banned, {:array, :string}, default: [] field :banned, {:array, :string}, default: []
field :show_only_pinned, :boolean, default: false field :show_only_pinned, :boolean, default: false
@@ -32,8 +47,7 @@ defmodule Claper.Presentations.PresentationState do
:chat_enabled, :chat_enabled,
:anonymous_chat_enabled, :anonymous_chat_enabled,
:show_only_pinned, :show_only_pinned,
:message_reaction_enabled, :message_reaction_enabled
:show_poll_results_enabled
]) ])
|> validate_required([]) |> validate_required([])
end end

View File

@@ -15,6 +15,17 @@ defmodule Claper.Release do
end end
end end
def seeds do
load_app()
for repo <- repos() do
{:ok, _, _} =
Ecto.Migrator.with_repo(repo, fn _repo ->
Code.eval_file("priv/repo/seeds.exs")
end)
end
end
def rollback(repo, version) do def rollback(repo, version) do
load_app() load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))

View File

@@ -11,7 +11,7 @@ defmodule Claper.Tasks.Converter do
Convert the presentation file to images. Convert the presentation file to images.
We use original hash :erlang.phash2(code-name) where the files are uploaded to send it to another folder with a new hash. This last stored in db. We use original hash :erlang.phash2(code-name) where the files are uploaded to send it to another folder with a new hash. This last stored in db.
""" """
def convert(user_id, file, hash, ext, presentation_file_id) do def convert(user_id, file, hash, ext, presentation_file_id, is_copy \\ false) do
presentation = Claper.Presentations.get_presentation_file!(presentation_file_id, [:event]) presentation = Claper.Presentations.get_presentation_file!(presentation_file_id, [:event])
{:ok, presentation} = {:ok, presentation} =
@@ -32,11 +32,11 @@ defmodule Claper.Tasks.Converter do
"#{hash}" "#{hash}"
]) ])
IO.puts("Starting conversion for #{hash}...") IO.puts("Starting conversion for #{hash}... (copy: #{is_copy})")
file_to_pdf(String.to_atom(ext), path, file) file_to_pdf(String.to_atom(ext), path, file)
|> pdf_to_jpg(path, presentation, user_id) |> pdf_to_jpg(path, presentation, user_id)
|> jpg_upload(hash, path, presentation, user_id) |> jpg_upload(hash, path, presentation, user_id, is_copy)
end end
@doc """ @doc """
@@ -113,7 +113,7 @@ defmodule Claper.Tasks.Converter do
failure(presentation, path, user_id) failure(presentation, path, user_id)
end end
defp jpg_upload(%Result{status: 0}, hash, path, presentation, user_id) do defp jpg_upload(%Result{status: 0}, hash, path, presentation, user_id, is_copy) do
files = Path.wildcard("#{path}/*.jpg") files = Path.wildcard("#{path}/*.jpg")
# assign new hash to avoid cache issues # assign new hash to avoid cache issues
@@ -147,14 +147,14 @@ defmodule Claper.Tasks.Converter do
end end
end end
if !is_nil(presentation.hash) do if !is_nil(presentation.hash) && !is_copy do
clear(presentation.hash) clear(presentation.hash)
end end
success(presentation, path, new_hash, length(files), user_id) success(presentation, path, new_hash, length(files), user_id)
end end
defp jpg_upload(_result, _hash, path, presentation, user_id) do defp jpg_upload(_result, _hash, path, presentation, user_id, _is_copy) do
failure(presentation, path, user_id) failure(presentation, path, user_id)
end end

View File

@@ -0,0 +1,70 @@
defmodule ClaperWeb.Lti.GradeController do
use ClaperWeb, :controller
alias Lti13.Tool.Services.AGS
alias Lti13.Tool.Services.AGS.Score
def create(conn, _params) do
resource_id = conn |> get_session(:resource_id) |> String.to_integer()
user_id = conn |> get_session(:user_id)
timestamp = get_timestamp()
case fetch_access_token() do
{:ok, access_token} ->
handle_line_item(conn, resource_id, user_id, timestamp, access_token)
{:error, msg} ->
conn |> send_resp(500, msg)
end
end
defp get_timestamp do
{:ok, dt} = DateTime.now("Etc/UTC")
DateTime.to_iso8601(dt)
end
defp fetch_access_token do
Lti13.Tool.Services.AccessToken.fetch_access_token(
%{
auth_token_url: "http://localhost.charlesproxy.com/mod/lti/token.php",
client_id: "NQQ8egz8Kj1s1qw",
auth_server: "http://localhost.charlesproxy.com"
},
[
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
"https://purl.imsglobal.org/spec/lti-ags/scope/score"
],
"http://localhost:4000"
)
end
defp handle_line_item(conn, resource_id, user_id, timestamp, access_token) do
case AGS.fetch_or_create_line_item(
"http://localhost.charlesproxy.com/mod/lti/services.php/2/lineitems?type_id=2",
resource_id,
fn -> 100.0 end,
"test",
access_token
) do
{:ok, line_item} ->
post_score(line_item, user_id, timestamp, access_token)
conn |> send_resp(200, "")
end
end
defp post_score(line_item, user_id, timestamp, access_token) do
AGS.post_score(
%Score{
scoreGiven: 90.0,
scoreMaximum: 100.0,
activityProgress: "Completed",
gradingProgress: "FullyGraded",
userId: user_id,
comment: "",
timestamp: timestamp
},
line_item,
access_token
)
end
end

View File

@@ -0,0 +1,72 @@
defmodule ClaperWeb.Lti.LaunchController do
alias ClaperWeb.UserAuth
use ClaperWeb, :controller
def login(conn, params) do
case Lti13.Tool.OidcLogin.oidc_login_redirect_url(params) do
{:ok, state, redirect_url} ->
conn
|> put_session("state", state)
|> redirect(external: redirect_url)
{:error, %{reason: :invalid_registration, msg: msg, issuer: _issuer, client_id: _client_id}} ->
render(conn, "error.html", msg: msg)
{:error, %{reason: _reason, msg: msg}} ->
render(conn, "error.html", msg: msg)
end
end
def launch(conn, params) do
session_state = Plug.Conn.get_session(conn, "state")
case Lti13.Tool.LaunchValidation.validate(params, session_state) do
{:ok,
%{
lti_user: lti_user,
claims: %{
"https://purl.imsglobal.org/spec/lti/claim/context" => %{
"label" => _course_label,
"title" => _course_title
},
"https://purl.imsglobal.org/spec/lti/claim/resource_link" => %{
"title" => _resource_title,
"id" => resource_id
},
"sub" => user_id
},
resource: resource
}} ->
conn =
conn
|> put_session(:resource_id, resource_id)
|> put_session(:user_id, user_id)
|> set_user_return_to(resource, lti_user)
UserAuth.log_in_user(conn, lti_user.user)
{:error, %{reason: :invalid_registration, msg: msg, issuer: _issuer, client_id: _client_id}} ->
render(conn, "error.html", msg: msg)
{:error,
%{
reason: :invalid_deployment,
msg: msg,
registration_id: _registration_id,
deployment_id: _deployment_id
}} ->
render(conn, "error.html", msg: msg)
{:error, %{reason: _reason, msg: msg}} ->
render(conn, "error.html", msg: msg)
end
end
defp set_user_return_to(conn, resource, lti_user) do
if resource.event.user_id == lti_user.user_id do
conn |> put_session(:user_return_to, ~p"/events")
else
conn |> put_session(:user_return_to, ~p"/e/#{resource.event.code}")
end
end
end

View File

@@ -0,0 +1,110 @@
defmodule ClaperWeb.Lti.RegistrationController do
use ClaperWeb, :controller
def new(conn, %{"openid_configuration" => conf, "registration_token" => token}) do
render(conn, "new.html", conf: conf, token: token)
end
def new(conn, _params) do
conn |> render(ClaperWeb.ErrorView, "404.html")
end
def create(conn, params) do
jwk = Lti13.Jwks.get_active_jwk()
%{"openid_configuration" => conf, "registration_token" => token} = params
body = Req.post!(conf).body
%{
"issuer" => issuer,
"registration_endpoint" => reg_endpoint,
"jwks_uri" => jwks_uri,
"authorization_endpoint" => auth_endpoint,
"token_endpoint" => token_endpoint
} = body
body =
Req.post!(reg_endpoint,
headers: [
{"Authorization", "Bearer #{token}"},
{"Content-type", "application/json"},
{"Accept", "application/json"}
],
body: body()
).body
%{
"client_id" => client_id,
"https://purl.imsglobal.org/spec/lti-tool-configuration" => %{
"deployment_id" => deployment_id
}
} = body
{:ok, registration} =
Lti13.Registrations.create_registration(%{
issuer: issuer,
client_id: client_id,
key_set_url: jwks_uri,
auth_token_url: token_endpoint,
auth_login_url: auth_endpoint,
auth_server: issuer,
tool_jwk_id: jwk.id
})
{:ok, _deployment} =
Lti13.Deployments.create_deployment(%{
deployment_id: deployment_id,
registration_id: registration.id
})
render(conn, "success.html")
end
defp body() do
endpoint_config = Application.get_env(:claper, ClaperWeb.Endpoint)[:url]
default_ports = [80, 443]
port_suffix =
if endpoint_config[:port] in default_ports, do: "", else: ":#{endpoint_config[:port]}"
url = "#{endpoint_config[:scheme]}://#{endpoint_config[:host]}#{port_suffix}"
Jason.encode_to_iodata!(%{
"application_type" => "web",
"response_types" => ["id_token"],
"grant_types" => ["implict", "client_credentials"],
"initiate_login_uri" => "#{url}/lti/login",
"redirect_uris" => [
"#{url}/lti/launch"
],
"client_name" => "Claper",
"jwks_uri" => "#{url}/.well-known/jwks.json",
"logo_uri" => "#{url}/images/logo.svg",
"token_endpoint_auth_method" => "private_key_jwt",
"scope" =>
"https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly",
"https://purl.imsglobal.org/spec/lti-tool-configuration" => %{
"domain" => "#{endpoint_config[:host]}#{port_suffix}",
"description" => "Create interactive presentations",
"target_link_uri" => "#{url}/lti/launch",
"custom_parameters" => %{
"context_start_date" => "$CourseSection.timeFrame.begin",
"context_end_date" => "$CourseSection.timeFrame.end",
"resource_title" => "$ResourceLink.title",
"resource_id" => "$ResourceLink.id"
},
"claims" => ["iss", "sub", "name", "email"]
}
})
end
def jwks(conn, _params) do
keys = Lti13.Jwks.get_all_public_keys()
conn
|> put_status(:ok)
|> json(keys)
end
end

View File

@@ -75,6 +75,7 @@ defmodule ClaperWeb.UserAuth do
It clears all session data for safety. See renew_session. It clears all session data for safety. See renew_session.
""" """
def log_out_user(conn) do def log_out_user(conn) do
logout_redirect_url = Application.get_env(:claper, :logout_redirect_url)
user_token = get_session(conn, :user_token) user_token = get_session(conn, :user_token)
user_token && Accounts.delete_session_token(user_token) user_token && Accounts.delete_session_token(user_token)
@@ -85,9 +86,14 @@ defmodule ClaperWeb.UserAuth do
conn conn
|> renew_session() |> renew_session()
|> delete_resp_cookie(@remember_me_cookie) |> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: "/") |> custom_logout_redirect(logout_redirect_url)
end end
defp custom_logout_redirect(conn, nil), do: conn |> redirect(to: "/")
defp custom_logout_redirect(conn, logout_redirect_url),
do: conn |> redirect(external: logout_redirect_url)
@doc """ @doc """
Authenticates the user by looking into the session Authenticates the user by looking into the session
and remember me token. and remember me token.

View File

@@ -0,0 +1,118 @@
defmodule ClaperWeb.UserOidcAuth do
@moduledoc """
Plug for OpenID Connect authentication.
"""
alias ClaperWeb.UserAuth
use ClaperWeb, :controller
import Phoenix.Controller
@doc false
def new(conn, _params) do
{:ok, redirect_uri} =
Oidcc.create_redirect_url(
Claper.OidcProviderConfig,
client_id(),
client_secret(),
opts()
)
uri = Enum.join(redirect_uri, "")
redirect(conn, external: uri)
end
def callback(conn, %{"code" => code} = _params) do
with {:ok,
%Oidcc.Token{
id: %Oidcc.Token.Id{token: id_token, claims: claims},
access: %Oidcc.Token.Access{token: access_token},
refresh: refresh_token
}} <-
Oidcc.retrieve_token(
code,
Claper.OidcProviderConfig,
client_id(),
client_secret(),
opts()
),
{:ok, oidc_user} <- validate_user(id_token, access_token, refresh_token, claims) do
conn
|> UserAuth.log_in_user(oidc_user.user)
else
{:error, _} ->
conn
|> put_flash(:error, "Cannot authenticate user.")
|> redirect(to: ~p"/users/log_in")
end
conn
end
defp config do
Application.get_env(:claper, :oidc)
end
defp client_id do
config()[:client_id]
end
defp client_secret do
config()[:client_secret]
end
defp provider_name do
config()[:provider_name]
end
defp scopes do
config()[:scopes]
end
defp base_url do
Application.get_env(:claper, ClaperWeb.Endpoint)[:base_url]
end
defp opts() do
url = base_url()
%{
redirect_uri: "#{url}/users/oidc/callback",
scopes: scopes()
}
end
defp format_refresh_token(%Oidcc.Token.Refresh{token: token}) do
token
end
defp format_refresh_token(:none) do
""
end
defp validate_user(id_token, access_token, refresh_token, claims) do
mappings = config()[:property_mappings]
case Claper.Accounts.get_or_create_user_with_oidc(%{
sub: claims["sub"],
issuer: claims["iss"],
name: claims["name"],
email: claims["email"],
provider: provider_name(),
expires_at: claims["exp"] |> DateTime.from_unix!() |> DateTime.to_naive(),
id_token: id_token,
access_token: access_token,
refresh_token: format_refresh_token(refresh_token),
groups: claims["groups"],
roles: claims[mappings["roles"]],
organization: claims[mappings["organization"]],
photo_url: claims[mappings["photo_url"]]
}) do
{:error, _} ->
{:error, %{reason: :invalid_user, msg: "Invalid user"}}
{:ok, user} ->
{:ok, user}
end
end
end

View File

@@ -5,8 +5,28 @@ defmodule ClaperWeb.UserSessionController do
alias ClaperWeb.UserAuth alias ClaperWeb.UserAuth
def new(conn, _params) do def new(conn, _params) do
oidc_auto_redirect_login = Application.get_env(:claper, :oidc)[:auto_redirect_login]
conn conn
|> render("new.html", error_message: nil) |> redirect_to_login(oidc_auto_redirect_login)
end
defp redirect_to_login(conn, true) do
conn |> redirect(to: "/users/oidc")
end
defp redirect_to_login(conn, false) do
oidc_provider_name = Application.get_env(:claper, :oidc)[:provider_name]
oidc_logo_url = Application.get_env(:claper, :oidc)[:logo_url]
oidc_enabled = Application.get_env(:claper, :oidc)[:enabled]
conn
|> render("new.html",
error_message: nil,
oidc_provider_name: oidc_provider_name,
oidc_logo_url: oidc_logo_url,
oidc_enabled: oidc_enabled
)
end end
# def create(conn, %{"user" => %{"email" => email}} = _user_params) do # def create(conn, %{"user" => %{"email" => email}} = _user_params) do
@@ -18,10 +38,19 @@ defmodule ClaperWeb.UserSessionController do
def create(conn, %{"user" => user_params}) do def create(conn, %{"user" => user_params}) do
%{"email" => email, "password" => password} = user_params %{"email" => email, "password" => password} = user_params
oidc_provider_name = Application.get_env(:claper, :oidc)[:provider_name]
oidc_logo_url = Application.get_env(:claper, :oidc)[:logo_url]
oidc_enabled = Application.get_env(:claper, :oidc)[:enabled]
if user = Accounts.get_user_by_email_and_password(email, password) do if user = Accounts.get_user_by_email_and_password(email, password) do
UserAuth.log_in_user(conn, user, user_params) UserAuth.log_in_user(conn, user, user_params)
else else
render(conn, "new.html", error_message: "Invalid email or password") render(conn, "new.html",
error_message: "Invalid email or password",
oidc_provider_name: oidc_provider_name,
oidc_logo_url: oidc_logo_url,
oidc_enabled: oidc_enabled
)
end end
end end

View File

@@ -7,7 +7,9 @@ defmodule ClaperWeb.Endpoint do
@session_options [ @session_options [
store: :cookie, store: :cookie,
key: "_claper_key", key: "_claper_key",
signing_salt: "Tg18Y2zU" signing_salt: "Tg18Y2zU",
# 30 days
max_age: 24 * 60 * 60 * 30
] ]
socket "/live", Phoenix.LiveView.Socket, socket "/live", Phoenix.LiveView.Socket,

View File

@@ -12,6 +12,13 @@ defmodule ClaperWeb.EmbedLive.FormComponent do
|> assign(assigns) |> assign(assigns)
|> assign_new(:dark, fn -> false end) |> assign_new(:dark, fn -> false end)
|> assign(:embeds, list_embeds(assigns)) |> assign(:embeds, list_embeds(assigns))
|> assign(:providers, [
{"YouTube", "youtube"},
{"Vimeo", "vimeo"},
{"Canva", "canva"},
{"Google Slides", "googleslides"},
{"Custom (iframe)", "custom"}
])
|> assign(:changeset, changeset)} |> assign(:changeset, changeset)}
end end

View File

@@ -18,24 +18,42 @@
required="true" required="true"
/> />
<div class="mt-3"> <div class="mt-3 flex gap-x-2 items-center">
<ClaperWeb.Component.Input.textarea <ClaperWeb.Component.Input.select
form={f} form={f}
key={:content} key={:provider}
name={gettext("The iframe component")} name={gettext("Provider")}
array={@providers}
labelClass={if @dark, do: "text-white"} labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"} fieldClass={if @dark, do: "bg-gray-700 text-white h-full"}
autofocus="true"
required="true" required="true"
/> />
<div class="flex-1">
<p></p>
<ClaperWeb.Component.Input.text
form={f}
key={:content}
name={
if Ecto.Changeset.get_field(@changeset, :provider) == "custom",
do: gettext("Iframe code"),
else: gettext("Link to the content")
}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
autofocus="true"
required="true"
/>
</div>
</div> </div>
<p class="text-gray-700 text-xl font-semibold mt-5"><%= gettext("Options") %></p>
<div class="flex gap-x-2 mb-5 mt-3"> <div class="flex gap-x-2 mb-5 mt-3">
<%= checkbox(f, :attendee_visibility, class: "h-4 w-5") %> <%= checkbox(f, :attendee_visibility, class: "h-4 w-5") %>
<%= label( <%= label(
f, f,
:attendee_visibility, :attendee_visibility,
gettext("Attendee can view the web content on their device"), gettext("Attendees can view the web content on their device"),
class: "text-sm font-medium" class: "text-sm font-medium"
) %> ) %>
</div> </div>

View File

@@ -60,7 +60,12 @@ defmodule ClaperWeb.EventLive.EmbedComponent do
<p class="text-white text-lg font-semibold mb-4"><%= @embed.title %></p> <p class="text-white text-lg font-semibold mb-4"><%= @embed.title %></p>
</div> </div>
<div class="flex flex-col space-y-3"> <div class="flex flex-col space-y-3">
<%= raw(@embed.content) %> <.live_component
id="embed-component"
module={ClaperWeb.EventLive.EmbedIframeComponent}
provider={@embed.provider}
content={@embed.content}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,47 @@
defmodule ClaperWeb.EventLive.EmbedIframeComponent do
use ClaperWeb, :live_component
def render(assigns) do
~H"""
<div id={@id} class="h-full w-full">
<%= case @provider do %>
<% "youtube" -> %>
<iframe
src={"https://www.youtube.com/embed/#{@content |> String.split("youtu.be/") |> Enum.at(1)}"}
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
>
</iframe>
<% "vimeo" -> %>
<iframe
src={"https://player.vimeo.com/video/#{@content |> String.split("vimeo.com/") |> Enum.at(1)}"}
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
>
</iframe>
<% "canva" -> %>
<iframe
src={"#{@content}?embed"}
frameborder="0"
allowfullscreen="allowfullscreen"
allow="fullscreen"
>
</iframe>
<% "googleslides" -> %>
<iframe
src={"#{@content |> String.replace("/pub", "/embed")}"}
frameborder="0"
allowfullscreen="allowfullscreen"
allow="fullscreen"
>
</iframe>
<% "custom" -> %>
<%= raw(@content) %>
<% end %>
</div>
"""
end
end

View File

@@ -73,7 +73,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
class="mt-2 flex flex-col space-y-2 sm:space-y-0 justify-between sm:flex-row items-center" class="mt-2 flex flex-col space-y-2 sm:space-y-0 justify-between sm:flex-row items-center"
> >
<div <div
id={"event-infos-#{@event.uuid}"} id={"event-infos-2-#{@event.uuid}"}
class="text-sm w-full sm:w-auto font-medium text-gray-700 flex justify-center space-x-1 sm:space-y-0 items-center relative" class="text-sm w-full sm:w-auto font-medium text-gray-700 flex justify-center space-x-1 sm:space-y-0 items-center relative"
> >
<button <button
@@ -101,7 +101,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<div <div
phx-hook="Dropdown" phx-hook="Dropdown"
id={"dropdown-#{@event.uuid}"} id={"dropdown-#{@event.uuid}"}
class="hidden rounded shadow-lg bg-white border px-2 py-1 absolute -left-1 top-9 w-max" class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max"
> >
<ul> <ul>
<li> <li>
@@ -175,16 +175,75 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<span><%= gettext("Terminate") %></span> <span><%= gettext("Terminate") %></span>
</.link> </.link>
</div> </div>
<div> <div class="flex items-start gap-x-2 relative text-sm ">
<%= if not @is_leader do %> <%= if not @is_leader do %>
<a <button
data-phx-link="patch" phx-click-away={JS.hide(to: "#dropdown-action-#{@event.uuid}")}
data-phx-link-state="push" phx-click={JS.toggle(to: "#dropdown-action-#{@event.uuid}")}
href={~p"/events/#{@event.uuid}/edit"} phx-target={@myself}
class="flex w-full lg:w-auto rounded-md tracking-wide focus:outline-none focus:shadow-outline text-primary-500 text-sm items-center" class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-gray-300 bg-gray-200"
> >
<span><%= gettext("Edit") %></span> <span class="mr-2"><%= gettext("Action") %></span>
</a> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
<div
phx-hook="Dropdown"
id={"dropdown-action-#{@event.uuid}"}
class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm"
>
<ul>
<li>
<a
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
href={~p"/events/#{@event.uuid}/edit"}
data-phx-link="patch"
data-phx-link-state="push"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5"
>
<path d="m5.433 13.917 1.262-3.155A4 4 0 0 1 7.58 9.42l6.92-6.918a2.121 2.121 0 0 1 3 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 0 1-.65-.65Z" />
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0 0 10 3H4.75A2.75 2.75 0 0 0 2 5.75v9.5A2.75 2.75 0 0 0 4.75 18h9.5A2.75 2.75 0 0 0 17 15.25V10a.75.75 0 0 0-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5Z" />
</svg>
<span><%= gettext("Edit") %></span>
</a>
</li>
<li>
<button
phx-value-id={@event.uuid}
phx-click="duplicate"
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path d="M7 3.5A1.5 1.5 0 0 1 8.5 2h3.879a1.5 1.5 0 0 1 1.06.44l3.122 3.12A1.5 1.5 0 0 1 17 6.622V12.5a1.5 1.5 0 0 1-1.5 1.5h-1v-3.379a3 3 0 0 0-.879-2.121L10.5 5.379A3 3 0 0 0 8.379 4.5H7v-1Z" />
<path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v9A1.5 1.5 0 0 0 4.5 18h7a1.5 1.5 0 0 0 1.5-1.5v-5.879a1.5 1.5 0 0 0-.44-1.06L9.44 6.439A1.5 1.5 0 0 0 8.378 6H4.5Z" />
</svg>
<span><%= gettext("Duplicate") %></span>
</button>
</li>
</ul>
</div>
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -196,16 +255,57 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<span class="text-sm text-supporting-red-500"> <span class="text-sm text-supporting-red-500">
<%= gettext("Error when processing the file") %> <%= gettext("Error when processing the file") %>
</span> </span>
<div> <div class="relative text-sm">
<%= if not @is_leader do %> <%= if not @is_leader do %>
<a <button
data-phx-link="patch" phx-click-away={JS.hide(to: "#dropdown-action-#{@event.uuid}")}
data-phx-link-state="push" phx-click={JS.toggle(to: "#dropdown-action-#{@event.uuid}")}
href={~p"/events/#{@event.uuid}/edit"} phx-target={@myself}
class="flex w-full lg:w-auto rounded-md tracking-wide focus:outline-none focus:shadow-outline text-primary-500 text-sm items-center" class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-gray-300 bg-gray-200"
> >
<span><%= gettext("Edit") %></span> <span class="mr-2"><%= gettext("Action") %></span>
</a> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
<div
phx-hook="Dropdown"
id={"dropdown-action-#{@event.uuid}"}
class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm"
>
<ul>
<li>
<a
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
href={~p"/events/#{@event.uuid}/edit"}
data-phx-link="patch"
data-phx-link-state="push"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5"
>
<path d="m5.433 13.917 1.262-3.155A4 4 0 0 1 7.58 9.42l6.92-6.918a2.121 2.121 0 0 1 3 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 0 1-.65-.65Z" />
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0 0 10 3H4.75A2.75 2.75 0 0 0 2 5.75v9.5A2.75 2.75 0 0 0 4.75 18h9.5A2.75 2.75 0 0 0 17 15.25V10a.75.75 0 0 0-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5Z" />
</svg>
<span><%= gettext("Edit") %></span>
</a>
</li>
</ul>
</div>
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -244,21 +344,64 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<span><%= gettext("View report") %></span> <span><%= gettext("View report") %></span>
</a> </a>
</div> </div>
<div> <div class="relative text-sm">
<%= if not @is_leader do %> <%= if not @is_leader do %>
<%= link(gettext("Delete"), <button
to: "#", phx-click-away={JS.hide(to: "#dropdown-action-#{@event.uuid}")}
phx_click: "delete", phx-click={JS.toggle(to: "#dropdown-action-#{@event.uuid}")}
phx_value_id: @event.uuid, phx-target={@myself}
data: [ class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-gray-300 bg-gray-200"
confirm: >
gettext( <span class="mr-2"><%= gettext("Action") %></span>
"This will delete all data related to your event, this cannot be undone. Confirm ?" <svg
) xmlns="http://www.w3.org/2000/svg"
], fill="none"
class: viewBox="0 0 24 24"
"flex w-full lg:w-auto rounded-md tracking-wide focus:outline-none focus:shadow-outline text-red-500 text-sm items-center" stroke-width="2.5"
) %> stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
<div
phx-hook="Dropdown"
id={"dropdown-action-#{@event.uuid}"}
class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm"
>
<ul>
<li>
<.link
phx-click="delete"
phx-value-id={@event.uuid}
data-confirm={
gettext(
"This will delete all data related to your event, this cannot be undone. Confirm ?"
)
}
class="py-2 px-2 rounded text-red-500 hover:bg-gray-100 flex items-center gap-x-2 flex items-center gap-x-2 cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-5 w-5"
>
<path
fill-rule="evenodd"
d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
clip-rule="evenodd"
/>
</svg>
<span><%= gettext("Delete") %></span>
</.link>
</li>
</ul>
</div>
<% end %> <% end %>
</div> </div>
</div> </div>

View File

@@ -248,7 +248,7 @@ defmodule ClaperWeb.EventLive.EventFormComponent do
event_params event_params
) do ) do
{:ok, event} -> {:ok, event} ->
handle_file_conversion(socket, hash, ext) handle_file_conversion(socket, hash, ext, event)
send_email_to_leaders(socket, event) send_email_to_leaders(socket, event)
@@ -262,17 +262,20 @@ defmodule ClaperWeb.EventLive.EventFormComponent do
end end
end end
defp handle_file_conversion(socket, hash, ext) do defp handle_file_conversion(socket, hash, ext, event) do
if is_nil(hash) || is_nil(ext) do if is_nil(hash) || is_nil(ext) do
:ok :ok
else else
files = Claper.Presentations.get_presentation_files_by_hash(event.presentation_file.hash)
Task.Supervisor.async_nolink(Claper.TaskSupervisor, fn -> Task.Supervisor.async_nolink(Claper.TaskSupervisor, fn ->
Claper.Tasks.Converter.convert( Claper.Tasks.Converter.convert(
socket.assigns.current_user.id, socket.assigns.current_user.id,
"original.#{ext}", "original.#{ext}",
hash, hash,
ext, ext,
socket.assigns.event.presentation_file.id socket.assigns.event.presentation_file.id,
files |> Enum.count() > 1
) )
end) end)
end end

View File

@@ -277,20 +277,6 @@
to={Date.add(Date.utc_today(), 365)} to={Date.add(Date.utc_today(), 365)}
/> />
</div> </div>
<%= if @action == :edit do %>
<div
phx-hook="QRCode"
id="qr"
data-code={@event.code}
data-get-url="true"
data-height="340"
data-width="340"
phx-update="ignore"
class="rounded-lg mx-auto bg-white w-64 h-64 p-12 items-center justify-center mb-14 hidden"
>
</div>
<% end %>
</div> </div>
<div <div
@@ -372,7 +358,7 @@
phx-click="remove-leader" phx-click="remove-leader"
phx-value-remove={l.data.temp_id} phx-value-remove={l.data.temp_id}
phx-target={@myself} phx-target={@myself}
class="md:ml-3 rounded-md bg-supporting-red-500 hover:bg-supporting-red-600 transition flex items-center mt-2 md:w-max text-white py-7 px-3 text-sm max-h-0" class="md:ml-3 rounded-md bg-supporting-red-500 hover:bg-supporting-red-600 transition flex items-center mt-2 md:w-max text-white py-5 px-3 text-sm max-h-0"
> >
<span><%= gettext("Remove") %></span> <span><%= gettext("Remove") %></span>
</button> </button>

View File

@@ -12,10 +12,12 @@ defmodule ClaperWeb.EventLive.Index do
Gettext.put_locale(ClaperWeb.Gettext, locale) Gettext.put_locale(ClaperWeb.Gettext, locale)
end end
code = for _ <- 1..5, into: "", do: <<Enum.random(~c"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")>>
changeset = changeset =
Events.change_event(%Event{}, %{ Events.change_event(%Event{}, %{
started_at: NaiveDateTime.utc_now(), started_at: NaiveDateTime.utc_now(),
code: Enum.random(1000..9999), code: code,
leaders: [] leaders: []
}) })
@@ -56,6 +58,8 @@ defmodule ClaperWeb.EventLive.Index do
@impl true @impl true
def handle_event("save", %{"event" => event_params}, socket) do def handle_event("save", %{"event" => event_params}, socket) do
code = for _ <- 1..5, into: "", do: <<Enum.random(~c"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")>>
case Claper.Events.create_event( case Claper.Events.create_event(
event_params event_params
|> Map.put("user_id", socket.assigns.current_user.id) |> Map.put("user_id", socket.assigns.current_user.id)
@@ -65,7 +69,10 @@ defmodule ClaperWeb.EventLive.Index do
"presentation_state" => %{} "presentation_state" => %{}
}) })
|> Map.put("started_at", NaiveDateTime.utc_now()) |> Map.put("started_at", NaiveDateTime.utc_now())
|> Map.put("code", "#{Enum.random(1000..9999)}") |> Map.put(
"code",
"#{code}"
)
) do ) do
{:ok, _event} -> {:ok, _event} ->
{:noreply, {:noreply,
@@ -81,11 +88,19 @@ defmodule ClaperWeb.EventLive.Index do
@impl true @impl true
def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do
event = Events.get_user_event!(current_user.id, id, [:presentation_file]) event = Events.get_user_event!(current_user.id, id, [:presentation_file])
hash = event.presentation_file.hash
files =
Claper.Presentations.get_presentation_files_by_hash(hash)
{:ok, _} = Events.delete_event(event) {:ok, _} = Events.delete_event(event)
Task.Supervisor.async_nolink(Claper.TaskSupervisor, fn -> if files |> Enum.empty?() && !is_nil(hash) do
Claper.Tasks.Converter.clear(event.presentation_file.hash) Task.Supervisor.async_nolink(Claper.TaskSupervisor, fn ->
end) Claper.Tasks.Converter.clear(event.presentation_file.hash)
end)
end
{:noreply, redirect(socket, to: ~p"/events")} {:noreply, redirect(socket, to: ~p"/events")}
end end
@@ -106,6 +121,13 @@ defmodule ClaperWeb.EventLive.Index do
{:noreply, redirect(socket, to: ~p"/events")} {:noreply, redirect(socket, to: ~p"/events")}
end end
@impl true
def handle_event("duplicate", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do
event = Events.get_user_event!(current_user.id, id)
{:ok, _} = Events.duplicate_event(current_user.id, event.uuid)
{:noreply, redirect(socket, to: ~p"/events")}
end
@impl true @impl true
def handle_event( def handle_event(
"toggle-quick-create", "toggle-quick-create",
@@ -142,11 +164,13 @@ defmodule ClaperWeb.EventLive.Index do
end end
defp apply_action(socket, :new, _params) do defp apply_action(socket, :new, _params) do
code = for _ <- 1..5, into: "", do: <<Enum.random(~c"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")>>
socket socket
|> assign(:page_title, gettext("Create")) |> assign(:page_title, gettext("Create"))
|> assign(:event, %Event{ |> assign(:event, %Event{
started_at: NaiveDateTime.utc_now(), started_at: NaiveDateTime.utc_now(),
code: Enum.random(1000..9999), code: code,
leaders: [] leaders: []
}) })
end end

View File

@@ -12,7 +12,7 @@
<% else %> <% else %>
<div <div
id="quick-create-modal" id="quick-create-modal"
class={"#{if @live_action != :quick_create, do: 'hidden' } fixed z-30 inset-0 overflow-y-auto p-4 sm:p-6 md:p-24 class={"#{if @live_action != :quick_create, do: "hidden" } fixed z-30 inset-0 overflow-y-auto p-4 sm:p-6 md:p-24
transform transition-all duration-150"} transform transition-all duration-150"}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"

View File

@@ -18,7 +18,7 @@ defmodule ClaperWeb.EventLive.Manage do
presentation_file: [:polls, :presentation_state] presentation_file: [:polls, :presentation_state]
]) ])
if is_nil(event) || not is_leader(socket, event) do if is_nil(event) || not leader?(socket, event) do
{:ok, {:ok,
socket socket
|> put_flash(:error, gettext("Event doesn't exist")) |> put_flash(:error, gettext("Event doesn't exist"))
@@ -26,6 +26,7 @@ defmodule ClaperWeb.EventLive.Manage do
else else
if connected?(socket) do if connected?(socket) do
Claper.Events.Event.subscribe(event.uuid) Claper.Events.Event.subscribe(event.uuid)
Claper.Presentations.subscribe(event.presentation_file.id)
Presence.track( Presence.track(
self(), self(),
@@ -54,33 +55,33 @@ defmodule ClaperWeb.EventLive.Manage do
|> assign(:pinned_post_count, length(pinned_posts)) |> assign(:pinned_post_count, length(pinned_posts))
|> assign(:question_count, length(questions)) |> assign(:question_count, length(questions))
|> assign(:post_count, length(posts)) |> assign(:post_count, length(posts))
|> assign(
:total_interactions,
Claper.Interactions.get_number_total_interactions(event.presentation_file.id)
)
|> assign( |> assign(
:form_submit_count, :form_submit_count,
length(form_submits) length(form_submits)
) )
|> assign(:polls, list_polls(socket, event.presentation_file.id))
|> assign(:forms, list_forms(socket, event.presentation_file.id))
|> assign(:embeds, list_embeds(socket, event.presentation_file.id))
|> assign(:create, nil) |> assign(:create, nil)
|> assign(:list_tab, :posts) |> assign(:list_tab, :posts)
|> assign(:create_action, :new) |> assign(:create_action, :new)
|> assign(:preview, false)
|> push_event("page-manage", %{ |> push_event("page-manage", %{
current_page: event.presentation_file.presentation_state.position, current_page: event.presentation_file.presentation_state.position,
timeout: 500 timeout: 500
}) })
|> poll_at_position(false) |> interactions_at_position(event.presentation_file.presentation_state.position)
|> form_at_position(false)
|> embed_at_position(false)
{:ok, socket} {:ok, socket}
end end
end end
defp is_leader(%{assigns: %{current_user: current_user}} = _socket, event) do defp leader?(%{assigns: %{current_user: current_user}} = _socket, event) do
Claper.Events.is_leaded_by(current_user.email, event) || event.user.id == current_user.id Claper.Events.leaded_by?(current_user.email, event) || event.user.id == current_user.id
end end
defp is_leader(_socket, _event), do: false defp leader?(_socket, _event), do: false
@impl true @impl true
def handle_info(%{event: "presence_diff"}, %{assigns: %{event: event}} = socket) do def handle_info(%{event: "presence_diff"}, %{assigns: %{event: event}} = socket) do
@@ -190,33 +191,92 @@ defmodule ClaperWeb.EventLive.Manage do
end end
@impl true @impl true
def handle_info({:poll_updated, poll}, socket) do def handle_info({:poll_created, poll}, socket) do
{:noreply, {:noreply,
socket socket
|> update(:current_poll, fn _current_poll -> poll end)} |> interactions_at_position(poll.position)}
end end
@impl true @impl true
def handle_info( def handle_info({:form_created, form}, socket) do
{:current_poll, poll}, {:noreply,
socket socket
) do |> interactions_at_position(form.position)}
{:noreply, socket |> assign(:current_poll, poll)} end
@impl true
def handle_info({:embed_created, embed}, socket) do
{:noreply,
socket
|> interactions_at_position(embed.position)}
end
@impl true
def handle_info({:poll_updated, poll}, socket) do
{:noreply,
socket
|> interactions_at_position(poll.position)}
end end
@impl true @impl true
def handle_info({:embed_updated, embed}, socket) do def handle_info({:embed_updated, embed}, socket) do
{:noreply, {:noreply,
socket socket
|> update(:current_embed, fn _current_embed -> embed end)} |> interactions_at_position(embed.position)}
end
@impl true
def handle_info({:form_updated, form}, socket) do
{:noreply,
socket
|> interactions_at_position(form.position)}
end
@impl true
def handle_info({:poll_deleted, poll}, socket) do
{:noreply,
socket
|> interactions_at_position(poll.position)}
end
@impl true
def handle_info({:embed_deleted, embed}, socket) do
{:noreply,
socket
|> interactions_at_position(embed.position)}
end
@impl true
def handle_info({:form_deleted, form}, socket) do
{:noreply,
socket
|> interactions_at_position(form.position)}
end end
@impl true @impl true
def handle_info( def handle_info(
{:current_embed, embed}, {:current_interaction, interaction},
socket socket
) do ) do
{:noreply, socket |> assign(:current_embed, embed)} if socket.assigns.current_interaction != interaction do
position = if interaction, do: interaction.position, else: socket.assigns.state.position
{:noreply,
socket
|> assign(:current_interaction, interaction)
|> interactions_at_position(position)}
else
{:noreply, socket}
end
end
@impl true
def handle_info({:state_updated, state}, socket) do
if state.position != socket.assigns.state.position do
{:noreply, socket |> assign(:state, state) |> interactions_at_position(state.position)}
else
{:noreply, socket |> assign(:state, state)}
end
end end
@impl true @impl true
@@ -249,203 +309,98 @@ defmodule ClaperWeb.EventLive.Manage do
{:noreply, {:noreply,
socket socket
|> assign(:state, new_state) |> assign(:state, new_state)
|> poll_at_position |> interactions_at_position(page)}
|> form_at_position
|> embed_at_position}
end
@impl true
def handle_event(
"import",
%{"event" => event_uuid},
%{assigns: %{current_user: current_user, event: current_event}} = socket
) do
try do
case Claper.Events.import(current_user.id, event_uuid, current_event.uuid) do
{:ok, _event} ->
{:noreply,
socket
|> put_flash(:info, gettext("Interactions imported successfully"))
|> redirect(to: ~p"/e/#{current_event.code}/manage")}
end
rescue
Ecto.NoResultsError ->
{:noreply,
socket
|> put_flash(:error, gettext("Interactions import failed"))
|> redirect(to: ~p"/e/#{current_event.code}/manage")}
end
end end
def handle_event("poll-set-active", %{"id" => id}, socket) do def handle_event("poll-set-active", %{"id" => id}, socket) do
Forms.disable_all(socket.assigns.event.presentation_file.id, socket.assigns.state.position) with poll <- Polls.get_poll!(id), :ok <- Claper.Interactions.enable_interaction(poll) do
Embeds.disable_all(socket.assigns.event.presentation_file.id, socket.assigns.state.position) Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_interaction, poll}
)
Polls.set_status( {:noreply,
id, socket
socket.assigns.event.presentation_file.id, |> assign(:current_interaction, poll)
socket.assigns.state.position, |> interactions_at_position(socket.assigns.state.position)}
true end
)
poll = Polls.get_poll!(id)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_poll, poll}
)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_form, nil}
)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_embed, nil}
)
{:noreply,
socket
|> assign(:polls, list_polls(socket, socket.assigns.event.presentation_file.id))
|> assign(:forms, list_forms(socket, socket.assigns.event.presentation_file.id))
|> assign(:embeds, list_embeds(socket, socket.assigns.event.presentation_file.id))}
end end
def handle_event("form-set-active", %{"id" => id}, socket) do def handle_event("form-set-active", %{"id" => id}, socket) do
Polls.disable_all(socket.assigns.event.presentation_file.id, socket.assigns.state.position) with form <- Forms.get_form!(id), :ok <- Claper.Interactions.enable_interaction(form) do
Embeds.disable_all(socket.assigns.event.presentation_file.id, socket.assigns.state.position) Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_interaction, form}
)
Forms.set_status( {:noreply,
id, socket
socket.assigns.event.presentation_file.id, |> assign(:current_interaction, form)
socket.assigns.state.position, |> interactions_at_position(socket.assigns.state.position)}
true end
)
form = Forms.get_form!(id)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_form, form}
)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_poll, nil}
)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_embed, nil}
)
{:noreply,
socket
|> assign(:polls, list_polls(socket, socket.assigns.event.presentation_file.id))
|> assign(:forms, list_forms(socket, socket.assigns.event.presentation_file.id))
|> assign(:embeds, list_embeds(socket, socket.assigns.event.presentation_file.id))}
end end
def handle_event("embed-set-active", %{"id" => id}, socket) do def handle_event("embed-set-active", %{"id" => id}, socket) do
Polls.disable_all(socket.assigns.event.presentation_file.id, socket.assigns.state.position) with embed <- Embeds.get_embed!(id), :ok <- Claper.Interactions.enable_interaction(embed) do
Forms.disable_all(socket.assigns.event.presentation_file.id, socket.assigns.state.position) Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_interaction, embed}
)
Embeds.set_status( {:noreply,
id, socket
socket.assigns.event.presentation_file.id, |> assign(:current_interaction, embed)
socket.assigns.state.position, |> interactions_at_position(socket.assigns.state.position)}
true end
)
embed = Embeds.get_embed!(id)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_embed, embed}
)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_poll, nil}
)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_form, nil}
)
{:noreply,
socket
|> assign(:polls, list_polls(socket, socket.assigns.event.presentation_file.id))
|> assign(:forms, list_forms(socket, socket.assigns.event.presentation_file.id))
|> assign(:embeds, list_embeds(socket, socket.assigns.event.presentation_file.id))}
end end
def handle_event("poll-set-inactive", %{"id" => id}, socket) do def handle_event("poll-set-inactive", %{"id" => id}, socket) do
Polls.set_status( with poll <- Polls.get_poll!(id), {:ok, _} <- Claper.Interactions.disable_interaction(poll) do
id, Phoenix.PubSub.broadcast(
socket.assigns.event.presentation_file.id, Claper.PubSub,
socket.assigns.state.position, "event:#{socket.assigns.event.uuid}",
false {:current_interaction, nil}
) )
end
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_poll, nil}
)
{:noreply, {:noreply,
socket socket
|> assign(:polls, list_polls(socket, socket.assigns.event.presentation_file.id))} |> assign(:current_interaction, nil)
|> interactions_at_position(socket.assigns.state.position)}
end end
def handle_event("form-set-inactive", %{"id" => id}, socket) do def handle_event("form-set-inactive", %{"id" => id}, socket) do
Forms.set_status( with form <- Forms.get_form!(id), {:ok, _} <- Claper.Interactions.disable_interaction(form) do
id, Phoenix.PubSub.broadcast(
socket.assigns.event.presentation_file.id, Claper.PubSub,
socket.assigns.state.position, "event:#{socket.assigns.event.uuid}",
false {:current_interaction, nil}
) )
end
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_form, nil}
)
{:noreply, {:noreply,
socket socket
|> assign(:forms, list_forms(socket, socket.assigns.event.presentation_file.id))} |> assign(:current_interaction, nil)
|> interactions_at_position(socket.assigns.state.position)}
end end
def handle_event("embed-set-inactive", %{"id" => id}, socket) do def handle_event("embed-set-inactive", %{"id" => id}, socket) do
Embeds.set_status( with embed <- Embeds.get_embed!(id),
id, {:ok, _} <- Claper.Interactions.disable_interaction(embed) do
socket.assigns.event.presentation_file.id, Phoenix.PubSub.broadcast(
socket.assigns.state.position, Claper.PubSub,
false "event:#{socket.assigns.event.uuid}",
) {:current_interaction, nil}
)
Phoenix.PubSub.broadcast( end
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_embed, nil}
)
{:noreply, {:noreply,
socket socket
|> assign(:embeds, list_embeds(socket, socket.assigns.event.presentation_file.id))} |> assign(:current_interaction, nil)
|> interactions_at_position(socket.assigns.state.position)}
end end
@impl true @impl true
@@ -561,23 +516,6 @@ defmodule ClaperWeb.EventLive.Manage do
{:noreply, socket |> assign(:state, new_state)} {:noreply, socket |> assign(:state, new_state)}
end end
@impl true
def handle_event(
"checked",
%{"key" => "show_poll_results_enabled", "value" => value},
%{assigns: %{state: state}} = socket
) do
{:ok, new_state} =
Claper.Presentations.update_presentation_state(
state,
%{
:show_poll_results_enabled => value
}
)
{:noreply, socket |> assign(:state, new_state)}
end
@impl true @impl true
def handle_event( def handle_event(
"checked", "checked",
@@ -704,9 +642,12 @@ defmodule ClaperWeb.EventLive.Manage do
poll = Polls.get_poll!(id) poll = Polls.get_poll!(id)
{:ok, _} = Polls.delete_poll(socket.assigns.event.uuid, poll) {:ok, _} = Polls.delete_poll(socket.assigns.event.uuid, poll)
{:noreply, {:noreply, socket}
socket end
|> assign(:polls, list_polls(socket, socket.assigns.event.presentation_file.id))}
@impl true
def handle_event("toggle-preview", _params, %{assigns: %{preview: preview}} = socket) do
{:noreply, socket |> assign(:preview, !preview)}
end end
@impl true @impl true
@@ -795,75 +736,12 @@ defmodule ClaperWeb.EventLive.Manage do
|> assign(:embed, embed) |> assign(:embed, embed)
end end
defp poll_at_position(
%{assigns: %{event: event, state: state}} = socket,
broadcast \\ true
) do
with poll <-
Claper.Polls.get_poll_current_position(
event.presentation_file.id,
state.position
) do
if broadcast do
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{event.uuid}",
{:current_poll, poll}
)
end
socket |> assign(:current_poll, poll)
end
end
defp form_at_position(
%{assigns: %{event: event, state: state}} = socket,
broadcast \\ true
) do
with form <-
Claper.Forms.get_form_current_position(
event.presentation_file.id,
state.position
) do
if broadcast do
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{event.uuid}",
{:current_form, form}
)
end
socket |> assign(:current_form, form)
end
end
defp pin(post, socket) do defp pin(post, socket) do
{:ok, _updated_post} = Claper.Posts.toggle_pin_post(post) {:ok, _updated_post} = Claper.Posts.toggle_pin_post(post)
{:noreply, socket} {:noreply, socket}
end end
defp embed_at_position(
%{assigns: %{event: event, state: state}} = socket,
broadcast \\ true
) do
with embed <-
Claper.Embeds.get_embed_current_position(
event.presentation_file.id,
state.position
) do
if broadcast do
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{event.uuid}",
{:current_embed, embed}
)
end
socket |> assign(:current_embed, embed)
end
end
defp ban(user, %{assigns: %{event: event, state: state}} = socket) do defp ban(user, %{assigns: %{event: event, state: state}} = socket) do
{:ok, new_state} = {:ok, new_state} =
Claper.Presentations.update_presentation_state(state, %{ Claper.Presentations.update_presentation_state(state, %{
@@ -879,6 +757,18 @@ defmodule ClaperWeb.EventLive.Manage do
{:noreply, socket |> assign(:state, new_state)} {:noreply, socket |> assign(:state, new_state)}
end end
defp interactions_at_position(
%{assigns: %{event: event}} = socket,
position,
broadcast \\ false
) do
with {:ok, interactions} <-
Claper.Interactions.get_interactions_at_position(event, position, broadcast) do
active = interactions |> Enum.find(& &1.enabled)
socket |> assign(:interactions, interactions) |> assign(:current_interaction, active)
end
end
defp list_pinned_posts(_socket, event_id) do defp list_pinned_posts(_socket, event_id) do
Claper.Posts.list_pinned_posts(event_id, [:event, :reactions]) Claper.Posts.list_pinned_posts(event_id, [:event, :reactions])
end end
@@ -891,18 +781,6 @@ defmodule ClaperWeb.EventLive.Manage do
Claper.Posts.list_questions(event_id, [:event, :reactions], String.to_atom(sort)) Claper.Posts.list_questions(event_id, [:event, :reactions], String.to_atom(sort))
end end
defp list_polls(_socket, presentation_file_id) do
Claper.Polls.list_polls(presentation_file_id)
end
defp list_forms(_socket, presentation_file_id) do
Claper.Forms.list_forms(presentation_file_id)
end
defp list_embeds(_socket, presentation_file_id) do
Claper.Embeds.list_embeds(presentation_file_id)
end
defp list_form_submits(_socket, presentation_file_id) do defp list_form_submits(_socket, presentation_file_id) do
Claper.Forms.list_form_submits(presentation_file_id, [:form]) Claper.Forms.list_form_submits(presentation_file_id, [:form])
end end

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
~H""" ~H"""
<div <div
id={"#{@id}"} id={"#{@id}"}
class={"#{if @post.body =~ "?", do: "border-supporting-yellow-400 border-2"} flex flex-col md:block px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white relative shadow-md text-black break-all mt-4"} class={"#{if @post.body =~ "?", do: "border-supporting-yellow-400 border-2"} flex flex-col md:block px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white relative shadow-md text-black break-all mt-2"}
> >
<div <div
:if={@post.body =~ "?"} :if={@post.body =~ "?"}

View File

@@ -5,7 +5,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
assigns = assigns |> assign_new(:show_shortcut, fn -> true end) assigns = assigns |> assign_new(:show_shortcut, fn -> true end)
~H""" ~H"""
<div> <div class="grid grid-cols-1 @md:grid-cols-2 space-x-2">
<div> <div>
<span class="font-semibold text-lg"> <span class="font-semibold text-lg">
<%= gettext("Presentation settings") %> <%= gettext("Presentation settings") %>
@@ -18,7 +18,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
shortcut={if @create == nil, do: "Q", else: nil} shortcut={if @create == nil, do: "Q", else: nil}
/> />
<span> <span>
<%= gettext("Show instructions") %> <%= gettext("Show instructions (QR Code)") %>
<code <code
:if={@show_shortcut} :if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg" class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
@@ -45,10 +45,18 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
</span> </span>
</div> </div>
<div class="flex space-x-2 items-center mt-3"> <div
class={"#{if !@state.chat_visible, do: "opacity-50"} flex space-x-2 items-center mt-3"}
title={
if !@state.chat_visible,
do: gettext("Show messages to change this option"),
else: nil
}
>
<ClaperWeb.Component.Input.check <ClaperWeb.Component.Input.check
key={:show_only_pinned} key={:show_only_pinned}
checked={@state.show_only_pinned} checked={@state.show_only_pinned}
disabled={!@state.chat_visible}
shortcut={if @create == nil, do: "E", else: nil} shortcut={if @create == nil, do: "E", else: nil}
/> />
<span> <span>
@@ -61,23 +69,6 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
</code> </code>
</span> </span>
</div> </div>
<div class="flex space-x-2 items-center mt-3">
<ClaperWeb.Component.Input.check
key={:poll_visible}
checked={@state.poll_visible}
shortcut={if @create == nil, do: "R", else: nil}
/>
<span>
<%= gettext("Show poll results") %>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
r
</code>
</span>
</div>
</div> </div>
<div> <div>
@@ -102,10 +93,18 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
</span> </span>
</div> </div>
<div class="flex space-x-2 items-center mt-3"> <div
class={"#{if !@state.chat_enabled, do: "opacity-50"} flex space-x-2 items-center mt-3"}
title={
if !@state.chat_enabled,
do: gettext("Enable messages to change this option"),
else: nil
}
>
<ClaperWeb.Component.Input.check <ClaperWeb.Component.Input.check
key={:anonymous_chat_enabled} key={:anonymous_chat_enabled}
checked={@state.anonymous_chat_enabled} checked={@state.anonymous_chat_enabled}
disabled={!@state.chat_enabled}
shortcut={if @create == nil, do: "S", else: nil} shortcut={if @create == nil, do: "S", else: nil}
/> />
<span> <span>
@@ -135,23 +134,6 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
</code> </code>
</span> </span>
</div> </div>
<div class="flex space-x-2 items-center mt-3">
<ClaperWeb.Component.Input.check
key={:show_poll_results_enabled}
checked={@state.show_poll_results_enabled}
shortcut={if @create == nil, do: "F", else: nil}
/>
<span>
<%= gettext("Show poll results") %>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
f
</code>
</span>
</div>
</div> </div>
</div> </div>
""" """

View File

@@ -60,7 +60,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
<button class="bg-gray-500 px-3 py-2 rounded-full flex justify-between items-center relative text-white"> <button class="bg-gray-500 px-3 py-2 rounded-full flex justify-between items-center relative text-white">
<div <div
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"} style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"}
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-full #{if opt.percentage == "100", do: 'rounded-r-full'}"} class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-full #{if opt.percentage == "100", do: "rounded-r-full"}"}
> >
</div> </div>
<div class="flex space-x-3 z-10 text-left"> <div class="flex space-x-3 z-10 text-left">
@@ -93,7 +93,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
> >
<div <div
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"} style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"}
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-full #{if opt.percentage == "100", do: 'rounded-r-full'}"} class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-full #{if opt.percentage == "100", do: "rounded-r-full"}"}
> >
</div> </div>
<div class="flex space-x-3 z-10 text-left"> <div class="flex space-x-3 z-10 text-left">

View File

@@ -22,19 +22,19 @@ defmodule ClaperWeb.EventLive.PostComponent do
<img src="/images/icons/ellipsis-horizontal-white.svg" class="h-5" /> <img src="/images/icons/ellipsis-horizontal-white.svg" class="h-5" />
</button> </button>
<%= if @post.name || is_a_leader(@post, @event, @leaders) || is_pinned(@post) do %> <%= if @post.name || leader?(@post, @event, @leaders) || pinned?(@post) do %>
<div class="inline-flex items-center"> <div class="inline-flex items-center">
<%= if @post.name do %> <%= if @post.name do %>
<p class="text-white text-xs font-semibold mb-2 mr-2"><%= @post.name %></p> <p class="text-white text-xs font-semibold mb-2 mr-2"><%= @post.name %></p>
<% end %> <% end %>
<%= if is_a_leader(@post, @event, @leaders) do %> <%= if leader?(@post, @event, @leaders) do %>
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2"> <div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2">
<img src="/images/icons/star.svg" class="h-3" /> <img src="/images/icons/star.svg" class="h-3" />
<span><%= gettext("Host") %></span> <span><%= gettext("Host") %></span>
</div> </div>
<% end %> <% end %>
<%= if is_pinned(@post) do %> <%= if pinned?(@post) do %>
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2 ml-1"> <div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2 ml-1">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -101,12 +101,12 @@ defmodule ClaperWeb.EventLive.PostComponent do
</div> </div>
<% else %> <% else %>
<div class="px-4 pt-3 pb-8 rounded-b-lg rounded-tr-lg bg-white text-black relative z-0 break-all"> <div class="px-4 pt-3 pb-8 rounded-b-lg rounded-tr-lg bg-white text-black relative z-0 break-all">
<%= if @post.name || is_a_leader(@post, @event, @leaders) do %> <%= if @post.name || leader?(@post, @event, @leaders) do %>
<div class="inline-flex items-center"> <div class="inline-flex items-center">
<%= if @post.name do %> <%= if @post.name do %>
<p class="text-black text-xs font-semibold mb-2 mr-2"><%= @post.name %></p> <p class="text-black text-xs font-semibold mb-2 mr-2"><%= @post.name %></p>
<% end %> <% end %>
<%= if is_a_leader(@post, @event, @leaders) do %> <%= if leader?(@post, @event, @leaders) do %>
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2"> <div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2">
<img src="/images/icons/star.svg" class="h-3" /> <img src="/images/icons/star.svg" class="h-3" />
<span><%= gettext("Host") %></span> <span><%= gettext("Host") %></span>
@@ -150,7 +150,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
</div> </div>
<% end %> <% end %>
<%= if is_pinned(@post) do %> <%= if pinned?(@post) do %>
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2 ml-1"> <div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2 ml-1">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -265,7 +265,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
""" """
end end
defp is_a_leader(post, event, leaders) do defp leader?(post, event, leaders) do
!is_nil(post.user_id) && !is_nil(post.user_id) &&
(post.user_id == event.user_id || (post.user_id == event.user_id ||
Enum.any?(leaders, fn leader -> Enum.any?(leaders, fn leader ->
@@ -273,7 +273,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
end)) end))
end end
defp is_pinned(post) do defp pinned?(post) do
post.pinned == true post.pinned == true
end end
end end

View File

@@ -2,9 +2,12 @@ defmodule ClaperWeb.EventLive.Presenter do
use ClaperWeb, :live_view use ClaperWeb, :live_view
alias ClaperWeb.Presence alias ClaperWeb.Presence
alias Claper.Embeds.Embed
alias Claper.Polls.Poll
alias Claper.Forms.Form
@impl true @impl true
def mount(%{"code" => code}, session, socket) do def mount(%{"code" => code} = params, session, socket) do
with %{"locale" => locale} <- session do with %{"locale" => locale} <- session do
Gettext.put_locale(ClaperWeb.Gettext, locale) Gettext.put_locale(ClaperWeb.Gettext, locale)
end end
@@ -15,7 +18,7 @@ defmodule ClaperWeb.EventLive.Presenter do
presentation_file: [:polls, :presentation_state] presentation_file: [:polls, :presentation_state]
]) ])
if is_nil(event) || not is_leader(socket, event) do if is_nil(event) || not leader?(socket, event) do
{:ok, {:ok,
socket socket
|> put_flash(:error, gettext("Event doesn't exist")) |> put_flash(:error, gettext("Event doesn't exist"))
@@ -52,6 +55,7 @@ defmodule ClaperWeb.EventLive.Presenter do
host host
) )
|> assign(:event, event) |> assign(:event, event)
|> assign(:iframe, !is_nil(params["iframe"]))
|> assign(:state, event.presentation_file.presentation_state) |> assign(:state, event.presentation_file.presentation_state)
|> assign(:posts, list_posts(socket, event.uuid)) |> assign(:posts, list_posts(socket, event.uuid))
|> assign(:pinned_posts, list_pinned_posts(socket, event.uuid)) |> assign(:pinned_posts, list_pinned_posts(socket, event.uuid))
@@ -71,11 +75,11 @@ defmodule ClaperWeb.EventLive.Presenter do
end) end)
end end
defp is_leader(%{assigns: %{current_user: current_user}} = _socket, event) do defp leader?(%{assigns: %{current_user: current_user}} = _socket, event) do
Claper.Events.is_leaded_by(current_user.email, event) || event.user.id == current_user.id Claper.Events.leaded_by?(current_user.email, event) || event.user.id == current_user.id
end end
defp is_leader(_socket, _event), do: false defp leader?(_socket, _event), do: false
@impl true @impl true
def handle_info(%{event: "presence_diff"}, %{assigns: %{event: event}} = socket) do def handle_info(%{event: "presence_diff"}, %{assigns: %{event: event}} = socket) do
@@ -238,26 +242,50 @@ defmodule ClaperWeb.EventLive.Presenter do
@impl true @impl true
def handle_info( def handle_info(
{:current_poll, poll}, {:current_interaction, %Poll{} = interaction},
socket socket
) do ) do
{:noreply, socket |> assign(:current_poll, poll)} {:noreply,
socket
|> assign(:current_poll, interaction)
|> assign(:current_embed, nil)
|> assign(:current_form, nil)}
end end
@impl true @impl true
def handle_info( def handle_info(
{:current_form, form}, {:current_interaction, %Embed{} = interaction},
socket socket
) do ) do
{:noreply, socket |> assign(:current_form, form)} {:noreply,
socket
|> assign(:current_embed, interaction)
|> assign(:current_poll, nil)
|> assign(:current_form, nil)}
end end
@impl true @impl true
def handle_info( def handle_info(
{:current_embed, embed}, {:current_interaction, %Form{} = interaction},
socket socket
) do ) do
{:noreply, socket |> assign(:current_embed, embed)} {:noreply,
socket
|> assign(:current_form, interaction)
|> assign(:current_poll, nil)
|> assign(:current_embed, nil)}
end
@impl true
def handle_info(
{:current_interaction, nil},
socket
) do
{:noreply,
socket
|> assign(:current_poll, nil)
|> assign(:current_embed, nil)
|> assign(:current_form, nil)}
end end
@impl true @impl true

View File

@@ -19,7 +19,7 @@
<!-- JOIN SCREEN --> <!-- JOIN SCREEN -->
<div <div
id="joinScreen" id="joinScreen"
class={"#{if @state.join_screen_visible, do: 'opacity-100 z-40', else: 'opacity-0'} h-full w-full flex flex-col justify-center bg-black absolute transition-opacity"} class={"#{if @state.join_screen_visible, do: "opacity-100 z-40", else: "opacity-0"} h-full w-full flex flex-col justify-center bg-black absolute transition-opacity"}
> >
<div class="h-full bg-black text-white bg-opacity-50 text-center flex flex-col items-center justify-center"> <div class="h-full bg-black text-white bg-opacity-50 text-center flex flex-col items-center justify-center">
<span class="font-semibold mb-10 sm:text-3xl md:text-4xl lg:text-6xl"> <span class="font-semibold mb-10 sm:text-3xl md:text-4xl lg:text-6xl">
@@ -46,24 +46,28 @@
<%= if @current_poll do %> <%= if @current_poll do %>
<div <div
id="poll" id="poll"
class={"#{if @state.poll_visible, do: 'opacity-100', else: 'opacity-0'} h-full w-full flex flex-col justify-center bg-black bg-opacity-90 absolute z-30 left-1/2 top-1/2 transform -translate-y-1/2 -translate-x-1/2 p-10 transition-opacity"} class={"#{if @state.poll_visible, do: "opacity-100", else: "opacity-0"} h-full w-full flex flex-col justify-center bg-black bg-opacity-90 absolute z-30 left-1/2 top-1/2 transform -translate-y-1/2 -translate-x-1/2 p-10 transition-opacity"}
> >
<div class="w-1/2 mx-auto"> <div class="w-full md:w-1/2 mx-auto h-full">
<p class="text-white font-bold text-5xl mb-24"><%= @current_poll.title %></p> <p class={"#{if @iframe, do: "text-xl mb-12", else: "text-5xl mb-24"} text-white font-bold text-center"}>
<%= @current_poll.title %>
</p>
<div class="flex flex-col space-y-10"> <div class={"#{if @iframe, do: "space-y-5", else: "space-y-8"} flex flex-col"}>
<%= if (length @current_poll.poll_opts) > 0 do %> <%= if (length @current_poll.poll_opts) > 0 do %>
<%= for opt <- @current_poll.poll_opts do %> <%= for opt <- @current_poll.poll_opts do %>
<div class="bg-gray-500 px-6 py-4 rounded-full flex justify-between items-center relative text-white"> <div class={"#{if @iframe, do: "py-1", else: "py-4"} bg-gray-500 px-6 rounded-full flex justify-between items-center relative text-white"}>
<div <div
style={"width: #{opt.percentage}%;"} style={"width: #{opt.percentage}%;"}
class="bg-gradient-to-r from-primary-500 to-secondary-500 rounded-full h-full absolute left-0 transition-all" class="bg-gradient-to-r from-primary-500 to-secondary-500 rounded-full h-full absolute left-0 transition-all"
> >
</div> </div>
<div class="flex space-x-3 z-10 text-left"> <div class="flex space-x-3 z-10 text-left">
<span class="flex-1 text-2xl font-bold"><%= opt.content %></span> <span class={"#{if @iframe, do: "text-base", else: "text-2xl"} flex-1 font-bold"}>
<%= opt.content %>
</span>
</div> </div>
<span class="text-2xl z-10 font-bold"> <span class={"#{if @iframe, do: "text-base", else: "text-2xl"} z-10 font-bold"}>
<%= opt.percentage %>% (<%= opt.vote_count %>) <%= opt.percentage %>% (<%= opt.vote_count %>)
</span> </span>
</div> </div>
@@ -76,7 +80,7 @@
<!-- MESSAGES --> <!-- MESSAGES -->
<div <div
id="slider-wrapper" id="slider-wrapper"
class={"w-full min-h-screen grid #{if (@state.chat_visible && @event.presentation_file.length > 0) || (@current_embed && @event.presentation_file.length == 0), do: 'grid-cols-[1fr_10px_1fr]', else: 'grid-cols-[1fr]'} items-center justify-center relative bg-black"} class={"w-full min-h-screen grid #{if (@state.chat_visible && @event.presentation_file.length > 0) || (@current_embed && @event.presentation_file.length == 0), do: "grid-cols-[1fr_10px_1fr]", else: "grid-cols-[1fr]"} items-center justify-center relative bg-black"}
phx-hook="Split" phx-hook="Split"
data-type="column" data-type="column"
data-gutter=".gutter-1" data-gutter=".gutter-1"
@@ -93,27 +97,38 @@
<div class={if post.__meta__.state == :deleted, do: "hidden"} id={"#{post.id}-post"}> <div class={if post.__meta__.state == :deleted, do: "hidden"} id={"#{post.id}-post"}>
<div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white shadow-md text-black break-word mt-4"> <div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white shadow-md text-black break-word mt-4">
<%= if post.name do %> <%= if post.name do %>
<p class="text-gray-400 text-lg font-semibold mb-2 mr-2"><%= post.name %></p> <p class={"#{if @iframe, do: "text-base", else: "text-lg"} text-gray-400 font-semibold mb-2 mr-2"}>
<%= post.name %>
</p>
<% end %> <% end %>
<p class="text-3xl"><%= post.body %></p> <p class={"#{if @iframe, do: "text-xl", else: "text-3xl"}"}><%= post.body %></p>
<%= if post.like_count > 0 || post.love_count > 0 || post.lol_count > 0 do %> <%= if post.like_count > 0 || post.love_count > 0 || post.lol_count > 0 do %>
<div class="flex h-6 space-x-2 text-lg text-gray-500 pb-3 items-center mt-5"> <div class="flex h-6 space-x-2 text-lg text-gray-500 pb-3 items-center mt-5">
<div class="flex items-center"> <div class="flex items-center">
<%= if post.like_count > 0 do %> <%= if post.like_count > 0 do %>
<img src="/images/icons/thumb.svg" class="h-7" /> <img
src="/images/icons/thumb.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.like_count %></span> <span class="ml-1"><%= post.like_count %></span>
<% end %> <% end %>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<%= if post.love_count > 0 do %> <%= if post.love_count > 0 do %>
<img src="/images/icons/heart.svg" class="h-7" /> <img
src="/images/icons/heart.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.love_count %></span> <span class="ml-1"><%= post.love_count %></span>
<% end %> <% end %>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<%= if post.lol_count > 0 do %> <%= if post.lol_count > 0 do %>
<img src="/images/icons/laugh.svg" class="h-7" /> <img
src="/images/icons/laugh.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.lol_count %></span> <span class="ml-1"><%= post.lol_count %></span>
<% end %> <% end %>
</div> </div>
@@ -129,27 +144,38 @@
<div class={if post.__meta__.state == :deleted, do: "hidden"} id={"#{post.id}-post"}> <div class={if post.__meta__.state == :deleted, do: "hidden"} id={"#{post.id}-post"}>
<div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white shadow-md text-black break-word mt-4"> <div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white shadow-md text-black break-word mt-4">
<%= if post.name do %> <%= if post.name do %>
<p class="text-gray-400 text-lg font-semibold mb-2 mr-2"><%= post.name %></p> <p class={"#{if @iframe, do: "text-base", else: "text-lg"} text-gray-400 font-semibold mb-2 mr-2"}>
<%= post.name %>
</p>
<% end %> <% end %>
<p class="text-3xl"><%= post.body %></p> <p class={"#{if @iframe, do: "text-xl", else: "text-3xl"}"}><%= post.body %></p>
<%= if post.like_count > 0 || post.love_count > 0 || post.lol_count > 0 do %> <%= if post.like_count > 0 || post.love_count > 0 || post.lol_count > 0 do %>
<div class="flex h-6 space-x-2 text-lg text-gray-500 pb-3 items-center mt-5"> <div class="flex h-6 space-x-2 text-lg text-gray-500 pb-3 items-center mt-5">
<div class="flex items-center"> <div class="flex items-center">
<%= if post.like_count > 0 do %> <%= if post.like_count > 0 do %>
<img src="/images/icons/thumb.svg" class="h-7" /> <img
src="/images/icons/thumb.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.like_count %></span> <span class="ml-1"><%= post.like_count %></span>
<% end %> <% end %>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<%= if post.love_count > 0 do %> <%= if post.love_count > 0 do %>
<img src="/images/icons/heart.svg" class="h-7" /> <img
src="/images/icons/heart.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.love_count %></span> <span class="ml-1"><%= post.love_count %></span>
<% end %> <% end %>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<%= if post.lol_count > 0 do %> <%= if post.lol_count > 0 do %>
<img src="/images/icons/laugh.svg" class="h-7" /> <img
src="/images/icons/laugh.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.lol_count %></span> <span class="ml-1"><%= post.lol_count %></span>
<% end %> <% end %>
</div> </div>
@@ -162,7 +188,7 @@
<% end %> <% end %>
</div> </div>
<div <div
class={"gutter-1 row-span-full cursor-col-resize col-[2] text-center text-sm leading-3 text-white #{if (!@state.chat_visible && @event.presentation_file.length > 0) || (!@current_embed && @event.presentation_file.length == 0), do: 'hidden'}"} class={"gutter-1 row-span-full cursor-col-resize col-[2] text-center text-sm leading-3 text-white #{if (!@state.chat_visible && @event.presentation_file.length > 0) || (!@current_embed && @event.presentation_file.length == 0), do: "hidden"}"}
style="writing-mode: vertical-rl" style="writing-mode: vertical-rl"
> >
••• •••
@@ -172,10 +198,15 @@
<%= if @current_embed do %> <%= if @current_embed do %>
<!-- EMBED --> <!-- EMBED -->
<div id="embed" class="max-h-screen w-full h-screen"> <div id="embed" class="max-h-screen w-full h-screen">
<%= raw(@current_embed.content) %> <.live_component
id="embed-component"
module={ClaperWeb.EventLive.EmbedIframeComponent}
provider={@current_embed.provider}
content={@current_embed.content}
/>
</div> </div>
<% end %> <% end %>
<div class={"#{if @current_embed, do: 'hidden', else: ''} text-center"} id="slider"> <div class={"#{if @current_embed, do: "hidden", else: ""} text-center"} id="slider">
<%= for index <- 1..max(1, @event.presentation_file.length) do %> <%= for index <- 1..max(1, @event.presentation_file.length) do %>
<%= if @event.presentation_file.length > 0 do %> <%= if @event.presentation_file.length > 0 do %>
<%= if Application.get_env(:claper, :presentations) |> Keyword.get(:storage) == "local" do %> <%= if Application.get_env(:claper, :presentations) |> Keyword.get(:storage) == "local" do %>
@@ -195,7 +226,10 @@
</div> </div>
</div> </div>
<!-- ONLINE BADGE --> <!-- ONLINE BADGE -->
<div class="absolute z-20 bottom-5 right-5 px-4 pt-3 pb-1 rounded-md bg-black shadow-md text-white flex-1"> <div
:if={!@iframe}
class="absolute z-20 bottom-5 right-5 px-4 pt-3 pb-1 rounded-md bg-black shadow-md text-white flex-1"
>
<div id="reacts" phx-hook="GlobalReacts" data-class-name="h-24" phx-update="ignore"></div> <div id="reacts" phx-hook="GlobalReacts" data-class-name="h-24" phx-update="ignore"></div>
<div class="inline-flex justify-between items-center text-white text-2xl"> <div class="inline-flex justify-between items-center text-white text-2xl">

View File

@@ -1,7 +1,8 @@
defmodule ClaperWeb.EventLive.Show do defmodule ClaperWeb.EventLive.Show do
alias Claper.Interactions
use ClaperWeb, :live_view use ClaperWeb, :live_view
alias Claper.{Posts, Polls, Forms, Embeds} alias Claper.{Posts, Polls, Forms}
alias ClaperWeb.Presence alias ClaperWeb.Presence
on_mount(ClaperWeb.AttendeeLiveAuth) on_mount(ClaperWeb.AttendeeLiveAuth)
@@ -87,9 +88,7 @@ defmodule ClaperWeb.EventLive.Show do
|> stream(:posts, posts) |> stream(:posts, posts)
|> assign(:post_count, Enum.count(posts)) |> assign(:post_count, Enum.count(posts))
|> starting_soon_assigns(event) |> starting_soon_assigns(event)
|> get_current_poll(event) |> get_current_interaction(event, event.presentation_file.presentation_state.position)
|> get_current_form(event)
|> get_current_embed(event)
|> check_leader(event) |> check_leader(event)
|> leader_list(event) |> leader_list(event)
@@ -109,7 +108,7 @@ defmodule ClaperWeb.EventLive.Show do
defp check_leader(%{assigns: %{current_user: current_user} = _assigns} = socket, event) defp check_leader(%{assigns: %{current_user: current_user} = _assigns} = socket, event)
when is_map(current_user) do when is_map(current_user) do
is_leader = is_leader =
current_user.id == event.user_id || Claper.Events.is_leaded_by(current_user.email, event) current_user.id == event.user_id || Claper.Events.leaded_by?(current_user.email, event)
socket |> assign(:is_leader, is_leader) socket |> assign(:is_leader, is_leader)
end end
@@ -227,39 +226,19 @@ defmodule ClaperWeb.EventLive.Show do
@impl true @impl true
def handle_info({:page_changed, page}, socket) do def handle_info({:page_changed, page}, socket) do
{:noreply, socket |> assign(:current_page, page) |> push_event("reset-global-react", %{})} {:noreply,
socket
|> assign(:current_page, page)
|> get_current_interaction(socket.assigns.event, page)
|> push_event("reset-global-react", %{})}
end end
@impl true @impl true
def handle_info( def handle_info(
{:current_poll, poll}, {:current_interaction, interaction},
socket socket
) do ) do
if is_nil(poll) do {:noreply, socket |> load_current_interaction(interaction)}
{:noreply, socket |> assign(:current_poll, poll)}
else
{:noreply, socket |> assign(:current_poll, poll) |> get_current_vote(poll.id)}
end
end
@impl true
def handle_info(
{:current_form, form},
socket
) do
if is_nil(form) do
{:noreply, socket |> assign(:current_form, form)}
else
{:noreply, socket |> assign(:current_form, form) |> get_current_form_submit(form.id)}
end
end
@impl true
def handle_info(
{:current_embed, embed},
socket
) do
{:noreply, socket |> assign(:current_embed, embed)}
end end
@impl true @impl true
@@ -296,60 +275,42 @@ defmodule ClaperWeb.EventLive.Show do
end end
@impl true @impl true
def handle_info({:poll_updated, poll}, socket) do def handle_info({:poll_updated, %Claper.Polls.Poll{enabled: true} = poll}, socket) do
if poll.enabled do
{:noreply,
socket
|> update(:current_poll, fn _current_poll -> poll end)}
else
{:noreply,
socket
|> update(:current_poll, fn _current_poll -> nil end)}
end
end
@impl true
def handle_info({:poll_deleted, _poll}, socket) do
{:noreply, {:noreply,
socket socket
|> update(:current_poll, fn _current_poll -> nil end)} |> load_current_interaction(poll)}
end end
@impl true @impl true
def handle_info({:form_updated, form}, socket) do def handle_info({:poll_deleted, %Claper.Polls.Poll{enabled: true}}, socket) do
if form.enabled do
{:noreply,
socket
|> update(:current_form, fn _current_form -> form end)}
else
{:noreply,
socket
|> update(:current_form, fn _current_form -> nil end)}
end
end
@impl true
def handle_info({:form_deleted, _form}, socket) do
{:noreply, {:noreply,
socket socket
|> update(:current_form, fn _current_form -> nil end)} |> update(:current_interaction, fn _current_interaction -> nil end)}
end end
@impl true @impl true
def handle_info({:embed_updated, embed}, socket) do def handle_info({:form_updated, %Claper.Forms.Form{enabled: true} = form}, socket) do
if embed.enabled do {:noreply,
{:noreply, socket
socket |> load_current_interaction(form)}
|> update(:current_embed, fn _current_embed -> embed end)}
else
{:noreply,
socket
|> update(:current_embed, fn _current_embed -> nil end)}
end
end end
@impl true @impl true
def handle_info({:embed_deleted, _embed}, socket) do def handle_info({:form_deleted, %Claper.Forms.Form{enabled: true}}, socket) do
{:noreply,
socket
|> update(:current_interaction, fn _current_interaction -> nil end)}
end
@impl true
def handle_info({:embed_updated, %Claper.Embeds.Embed{enabled: true} = embed}, socket) do
{:noreply,
socket
|> load_current_interaction(embed)}
end
@impl true
def handle_info({:embed_deleted, %Claper.Embeds.Embed{enabled: true}}, socket) do
{:noreply, {:noreply,
socket socket
|> update(:current_embed, fn _current_embed -> nil end)} |> update(:current_embed, fn _current_embed -> nil end)}
@@ -541,7 +502,7 @@ defmodule ClaperWeb.EventLive.Show do
def handle_event( def handle_event(
"select-poll-opt", "select-poll-opt",
%{"opt" => opt}, %{"opt" => opt},
%{assigns: %{current_poll: %{multiple: true}}} = socket %{assigns: %{current_interaction: %{multiple: true}}} = socket
) do ) do
if Enum.member?(socket.assigns.selected_poll_opt, opt) do if Enum.member?(socket.assigns.selected_poll_opt, opt) do
{:noreply, {:noreply,
@@ -559,7 +520,7 @@ defmodule ClaperWeb.EventLive.Show do
def handle_event( def handle_event(
"select-poll-opt", "select-poll-opt",
%{"opt" => opt}, %{"opt" => opt},
%{assigns: %{current_poll: %{multiple: false}}} = socket %{assigns: %{current_interaction: %{multiple: false}}} = socket
) do ) do
{:noreply, socket |> assign(:selected_poll_opt, [opt])} {:noreply, socket |> assign(:selected_poll_opt, [opt])}
end end
@@ -572,13 +533,15 @@ defmodule ClaperWeb.EventLive.Show do
) )
when is_map(current_user) do when is_map(current_user) do
opts = Enum.map(opts, fn opt -> Integer.parse(opt) |> elem(0) end) opts = Enum.map(opts, fn opt -> Integer.parse(opt) |> elem(0) end)
poll_opts = Enum.map(opts, fn opt -> Enum.at(socket.assigns.current_poll.poll_opts, opt) end)
poll_opts =
Enum.map(opts, fn opt -> Enum.at(socket.assigns.current_interaction.poll_opts, opt) end)
case Claper.Polls.vote( case Claper.Polls.vote(
current_user.id, current_user.id,
socket.assigns.event.uuid, socket.assigns.event.uuid,
poll_opts, poll_opts,
socket.assigns.current_poll.id socket.assigns.current_interaction.id
) do ) do
{:ok, poll} -> {:ok, poll} ->
{:noreply, socket |> get_current_vote(poll.id)} {:noreply, socket |> get_current_vote(poll.id)}
@@ -592,13 +555,15 @@ defmodule ClaperWeb.EventLive.Show do
%{assigns: %{attendee_identifier: attendee_identifier, selected_poll_opt: opts}} = socket %{assigns: %{attendee_identifier: attendee_identifier, selected_poll_opt: opts}} = socket
) do ) do
opts = Enum.map(opts, fn opt -> Integer.parse(opt) |> elem(0) end) opts = Enum.map(opts, fn opt -> Integer.parse(opt) |> elem(0) end)
poll_opts = Enum.map(opts, fn opt -> Enum.at(socket.assigns.current_poll.poll_opts, opt) end)
poll_opts =
Enum.map(opts, fn opt -> Enum.at(socket.assigns.current_interaction.poll_opts, opt) end)
case Claper.Polls.vote( case Claper.Polls.vote(
attendee_identifier, attendee_identifier,
socket.assigns.event.uuid, socket.assigns.event.uuid,
poll_opts, poll_opts,
socket.assigns.current_poll.id socket.assigns.current_interaction.id
) do ) do
{:ok, poll} -> {:ok, poll} ->
{:noreply, socket |> get_current_vote(poll.id)} {:noreply, socket |> get_current_vote(poll.id)}
@@ -681,44 +646,6 @@ defmodule ClaperWeb.EventLive.Show do
Posts.list_posts(event_id, [:event, :reactions, :user]) Posts.list_posts(event_id, [:event, :reactions, :user])
end end
defp get_current_poll(socket, event) do
with poll <-
Polls.get_poll_current_position(
event.presentation_file.id,
event.presentation_file.presentation_state.position
) do
if is_nil(poll) do
socket |> assign(:current_poll, poll)
else
socket |> assign(:current_poll, poll) |> get_current_vote(poll.id)
end
end
end
defp get_current_form(socket, event) do
with form <-
Forms.get_form_current_position(
event.presentation_file.id,
event.presentation_file.presentation_state.position
) do
if is_nil(form) do
socket |> assign(:current_form, form)
else
socket |> assign(:current_form, form) |> get_current_form_submit(form.id)
end
end
end
defp get_current_embed(socket, event) do
with embed <-
Embeds.get_embed_current_position(
event.presentation_file.id,
event.presentation_file.presentation_state.position
) do
socket |> assign(:current_embed, embed)
end
end
defp get_current_vote(%{assigns: %{current_user: current_user}} = socket, poll_id) defp get_current_vote(%{assigns: %{current_user: current_user}} = socket, poll_id)
when is_map(current_user) do when is_map(current_user) do
vote = Polls.get_poll_vote(current_user.id, poll_id) vote = Polls.get_poll_vote(current_user.id, poll_id)
@@ -765,4 +692,23 @@ defmodule ClaperWeb.EventLive.Show do
socket socket
|> assign(:page_title, "##{socket.assigns.event.code} - #{socket.assigns.event.name}") |> assign(:page_title, "##{socket.assigns.event.code} - #{socket.assigns.event.name}")
end end
defp get_current_interaction(socket, event, position) do
with interaction <- Interactions.get_active_interaction(event, position) do
socket |> assign(:current_interaction, interaction) |> load_current_interaction(interaction)
end
end
defp load_current_interaction(socket, %Polls.Poll{} = interaction) do
poll = Polls.set_percentages(interaction)
socket |> assign(:current_interaction, poll) |> get_current_vote(poll.id)
end
defp load_current_interaction(socket, %Forms.Form{} = interaction) do
socket |> assign(:current_interaction, interaction) |> get_current_form_submit(interaction.id)
end
defp load_current_interaction(socket, interaction) do
socket |> assign(:current_interaction, interaction)
end
end end

View File

@@ -55,62 +55,62 @@
</div> </div>
</div> </div>
<%= if @current_poll do %> <%= case @current_interaction do %>
<div <% %Claper.Polls.Poll{} -> %>
id="poll-wrapper-parent" <div
class="animate__animated animate__zoomInDown w-full lg:w-1/3 lg:mx-auto fixed top-16 z-10 px-2 lg:px-7 pb-6 max-h-screen overflow-y-auto" id="poll-wrapper-parent"
> class="animate__animated animate__zoomInDown w-full lg:w-1/3 lg:mx-auto fixed top-16 z-10 px-2 lg:px-7 pb-6 max-h-screen overflow-y-auto"
<div class="transition-all" id="poll-wrapper"> >
<.live_component <div class="transition-all" id="poll-wrapper">
module={ClaperWeb.EventLive.PollComponent} <.live_component
id={"#{@current_poll.id}-poll"} module={ClaperWeb.EventLive.PollComponent}
poll={@current_poll} id={"#{@current_interaction.id}-poll"}
current_user={@current_user} poll={@current_interaction}
attendee_identifier={@attendee_identifier} current_user={@current_user}
event={@event} attendee_identifier={@attendee_identifier}
selected_poll_opt={@selected_poll_opt} event={@event}
current_poll_vote={@current_poll_vote} selected_poll_opt={@selected_poll_opt}
show_results={@state.show_poll_results_enabled} current_poll_vote={@current_poll_vote}
/> show_results={@current_interaction.show_results}
/>
</div>
</div> </div>
</div> <% %Claper.Forms.Form{} -> %>
<% end %> <div
id="form-wrapper-parent"
<%= if @current_form do %> class="animate__animated animate__zoomInDown w-full lg:w-1/3 lg:mx-auto fixed top-16 z-10 px-2 pb-6 lg:px-7 max-h-screen overflow-y-auto"
<div >
id="form-wrapper-parent" <div class="transition-all" id="form-wrapper">
class="animate__animated animate__zoomInDown w-full lg:w-1/3 lg:mx-auto fixed top-16 z-10 px-2 pb-6 lg:px-7 max-h-screen overflow-y-auto" <.live_component
> module={ClaperWeb.EventLive.FormComponent}
<div class="transition-all" id="form-wrapper"> id={"#{@current_interaction.id}-form"}
<.live_component form={@current_interaction}
module={ClaperWeb.EventLive.FormComponent} current_user={@current_user}
id={"#{@current_form.id}-form"} attendee_identifier={@attendee_identifier}
form={@current_form} event={@event}
current_user={@current_user} current_form_submit={@current_form_submit}
attendee_identifier={@attendee_identifier} />
event={@event} </div>
current_form_submit={@current_form_submit}
/>
</div> </div>
</div> <% %Claper.Embeds.Embed{} -> %>
<% end %> <div
:if={@current_interaction.attendee_visibility == true}
<%= if @current_embed != nil and @current_embed.attendee_visibility == true do %> id="embed-wrapper-parent"
<div class="animate__animated animate__zoomInDown w-full lg:w-1/3 lg:mx-auto fixed top-16 z-10 px-2 pb-6 lg:px-7 max-h-screen overflow-y-auto"
id="embed-wrapper-parent" >
class="animate__animated animate__zoomInDown w-full lg:w-1/3 lg:mx-auto fixed top-16 z-10 px-2 pb-6 lg:px-7 max-h-screen overflow-y-auto" <div class="transition-all" id="embed-wrapper">
> <.live_component
<div class="transition-all" id="embed-wrapper"> module={ClaperWeb.EventLive.EmbedComponent}
<.live_component id={"#{@current_interaction.id}-embed"}
module={ClaperWeb.EventLive.EmbedComponent} embed={@current_interaction}
id={"#{@current_embed.id}-embed"} current_user={@current_user}
embed={@current_embed} attendee_identifier={@attendee_identifier}
current_user={@current_user} event={@event}
attendee_identifier={@attendee_identifier} />
event={@event} </div>
/>
</div> </div>
</div> <% _ -> %>
<!-- Handle any other types of interactions here if needed -->
<% end %> <% end %>
<div <div

View File

@@ -47,10 +47,10 @@
phx-click="remove_field" phx-click="remove_field"
phx-value-field={i.index} phx-value-field={i.index}
phx-target={@myself} phx-target={@myself}
class="rounded-md bg-red-500 hover:bg-red-600 transition block mt-4" class="rounded-md bg-red-500 hover:bg-red-600 transition block mt-6"
> >
<svg <svg
class="text-white h-10 transform rotate-45" class="text-white h-8 transform rotate-45"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"

View File

@@ -19,14 +19,9 @@
/> />
</div> </div>
<div class="flex gap-x-2 mb-5">
<%= checkbox(f, :multiple, class: "h-5 w-5") %>
<%= label(f, :multiple, gettext("Multiple answers"), class: "text-sm font-medium") %>
</div>
<%= inputs_for f, :poll_opts, fn i -> %> <%= inputs_for f, :poll_opts, fn i -> %>
<div class="flex space-x-3 mt-3 items-center justify-start"> <div class="flex space-x-3 mt-3 items-center justify-start">
<div> <div class="flex-1">
<ClaperWeb.Component.Input.text <ClaperWeb.Component.Input.text
form={i} form={i}
labelClass={if @dark, do: "text-white"} labelClass={if @dark, do: "text-white"}
@@ -84,6 +79,23 @@
</svg> </svg>
</button> </button>
<p class="text-gray-700 text-xl font-semibold"><%= gettext("Options") %></p>
<div class="flex gap-x-2 mb-5 mt-3">
<%= checkbox(f, :show_results, class: "h-4 w-4") %>
<%= label(
f,
:show_results,
gettext("Attendees can see the results on their device"),
class: "text-sm font-medium"
) %>
</div>
<div class="flex gap-x-2 mb-5">
<%= checkbox(f, :multiple, class: "h-4 w-4") %>
<%= label(f, :multiple, gettext("Multiple answers"), class: "text-sm font-medium") %>
</div>
<div class="flex space-x-3"> <div class="flex space-x-3">
<button <button
type="submit" type="submit"

View File

@@ -195,7 +195,7 @@
> >
<div <div
style={"width: #{percentage}%;"} style={"width: #{percentage}%;"}
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-full #{if percentage == "100", do: 'rounded-r-full'}"} class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-full #{if percentage == "100", do: "rounded-r-full"}"}
> >
</div> </div>
<div class="flex space-x-3 z-10 text-left"> <div class="flex space-x-3 z-10 text-left">

View File

@@ -14,6 +14,12 @@ defmodule ClaperWeb.UserSettingsLive.Show do
email_changeset = Accounts.User.email_changeset(%Accounts.User{}, %{}) email_changeset = Accounts.User.email_changeset(%Accounts.User{}, %{})
password_changeset = Accounts.User.password_changeset(%Accounts.User{}, %{}) password_changeset = Accounts.User.password_changeset(%Accounts.User{}, %{})
oidc_accounts =
Accounts.get_all_oidc_users_by_email(socket.assigns.current_user.email) |> List.wrap()
lti_accounts =
Lti13.Users.get_all_users_by_email(socket.assigns.current_user.email) |> List.wrap()
preferences_changeset = preferences_changeset =
Accounts.User.preferences_changeset( Accounts.User.preferences_changeset(
socket.assigns.current_user, socket.assigns.current_user,
@@ -24,7 +30,14 @@ defmodule ClaperWeb.UserSettingsLive.Show do
socket socket
|> assign(:email_changeset, email_changeset) |> assign(:email_changeset, email_changeset)
|> assign(:password_changeset, password_changeset) |> assign(:password_changeset, password_changeset)
|> assign(:preferences_changeset, preferences_changeset)} |> assign(:preferences_changeset, preferences_changeset)
|> assign(:is_external_user, oidc_accounts != [] or lti_accounts != [])
|> assign(:oidc_accounts, oidc_accounts)
|> assign(:lti_accounts, lti_accounts)
|> assign(
:allow_unlink_external_provider,
Application.get_env(:claper, :allow_unlink_external_provider)
)}
end end
@impl true @impl true
@@ -50,11 +63,59 @@ defmodule ClaperWeb.UserSettingsLive.Show do
) )
end end
defp apply_action(socket, :set_password, _params) do
socket
|> assign(:page_title, gettext("Set a new password"))
|> assign(
:page_description,
gettext("Set a new password for your account before unlinking it.")
)
end
defp apply_action(socket, :show, _params) do defp apply_action(socket, :show, _params) do
socket socket
|> assign(:page_title, gettext("Settings")) |> assign(:page_title, gettext("Settings"))
end end
@impl true
def handle_event(
"unlink",
_params,
socket
)
when length(socket.assigns.oidc_accounts) + length(socket.assigns.lti_accounts) == 1 and
socket.assigns.current_user.is_randomized_password do
{:noreply, socket |> redirect(to: ~p"/users/settings/set/password")}
end
@impl true
def handle_event(
"unlink",
%{"issuer" => issuer} = _params,
socket
) do
Claper.Accounts.remove_oidc_user(socket.assigns.current_user, issuer)
{:noreply,
socket
|> put_flash(:info, gettext("The account has been unlinked."))
|> push_navigate(to: ~p"/users/settings")}
end
@impl true
def handle_event(
"unlink",
%{"registration_id" => registration_id} = _params,
socket
) do
Lti13.Users.remove_user(socket.assigns.current_user, registration_id)
{:noreply,
socket
|> put_flash(:info, gettext("The account has been unlinked."))
|> push_navigate(to: ~p"/users/settings")}
end
@impl true @impl true
def handle_event("save", %{"action" => "update_email"} = params, socket) do def handle_event("save", %{"action" => "update_email"} = params, socket) do
%{"user" => user_params} = params %{"user" => user_params} = params
@@ -128,6 +189,27 @@ defmodule ClaperWeb.UserSettingsLive.Show do
end end
end end
@impl true
def handle_event("save", %{"action" => "set_password"} = params, socket) do
%{"user" => user_params} = params
user = socket.assigns.current_user
case Accounts.set_user_password(user, user_params) do
{:ok, _applied_user} ->
{:noreply,
socket
|> put_flash(
:info,
gettext("Your password has been set, you can now unlink your account.")
)
|> redirect(to: ~p"/users/settings")}
{:error, changeset} ->
{:noreply, assign(socket, :password_changeset, changeset)}
end
end
@impl true @impl true
def handle_event("delete_account", _params, %{assigns: %{current_user: user}} = socket) do def handle_event("delete_account", _params, %{assigns: %{current_user: user}} = socket) do
Accounts.delete(user) Accounts.delete(user)

View File

@@ -87,6 +87,49 @@
</.live_component> </.live_component>
<% end %> <% end %>
<%= if @live_action in [:set_password] do %>
<.live_component
module={ClaperWeb.ModalComponent}
class="hidden"
id="modal-wrapper"
title={@page_title}
description={@page_description}
return_to={~p"/users/settings"}
>
<div>
<.form
:let={f}
for={@password_changeset}
phx-submit="save"
id="set_password"
class="mt-5 md:flex md:items-end gap-x-2"
>
<%= hidden_input(f, :action, name: "action", value: "set_password") %>
<ClaperWeb.Component.Input.password
form={f}
key={:password}
name={gettext("New password")}
required="true"
/>
<ClaperWeb.Component.Input.password
form={f}
key={:password_confirmation}
name={gettext("Confirm password")}
required="true"
/>
<%= submit(gettext("Save"),
phx_disable_with: "Saving...",
class:
"mt-2 w-full h-14 inline-flex transition-all items-center justify-center px-4 py-2 shadow-sm font-medium rounded-md text-white bg-black hover:bg-primary-500 md:mt-0 md:ml-3 md:w-auto md:text-sm"
) %>
</.form>
</div>
</.live_component>
<% end %>
<div class="shadow overflow-hidden sm:rounded-lg"> <div class="shadow overflow-hidden sm:rounded-lg">
<div class="py-5"> <div class="py-5">
<h3 class="text-lg leading-6 font-medium text-gray-900"> <h3 class="text-lg leading-6 font-medium text-gray-900">
@@ -104,7 +147,7 @@
</dt> </dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<span class="flex-grow"><%= @current_user.email %></span> <span class="flex-grow"><%= @current_user.email %></span>
<span class="ml-4 flex-shrink-0"> <span :if={!@is_external_user} class="ml-4 flex-shrink-0">
<.link <.link
patch={~p"/users/settings/edit/email"} patch={~p"/users/settings/edit/email"}
class="rounded-md font-medium text-purple-600 hover:text-purple-500" class="rounded-md font-medium text-purple-600 hover:text-purple-500"
@@ -114,10 +157,10 @@
</span> </span>
</dd> </dd>
<dt class="text-sm font-medium text-gray-500"> <dt :if={!@is_external_user} class="text-sm font-medium text-gray-500">
<%= gettext("Password") %> <%= gettext("Password") %>
</dt> </dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"> <dd :if={!@is_external_user} class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<span class="flex-grow">********</span> <span class="flex-grow">********</span>
<span class="ml-4 flex-shrink-0"> <span class="ml-4 flex-shrink-0">
<.link <.link
@@ -128,6 +171,67 @@
</.link> </.link>
</span> </span>
</dd> </dd>
<dt :if={@is_external_user} class="text-sm font-medium text-gray-500">
<%= gettext("Accounts linked") %>
</dt>
<dd class="text-sm text-gray-900 sm:col-span-2">
<%= for account <- @oidc_accounts do %>
<div class="text-sm text-gray-900 bg-white rounded-md py-2 px-4 shadow-base flex gap-x-2 items-center justify-start mt-2 sm:mt-0 mb-2">
<img src="/images/icons/openid.png" class="w-5" />
<span class="flex-grow flex items-center gap-x-2">
<span><%= account.provider %></span>
<div
:if={account.organization}
class="text-gray-500 text-xs flex items-center gap-x-1"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-3"
>
<path
fill-rule="evenodd"
d="M1.75 2a.75.75 0 0 0 0 1.5H2v9h-.25a.75.75 0 0 0 0 1.5h1.5a.75.75 0 0 0 .75-.75v-1.5a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 .75.75v1.5c0 .414.336.75.75.75h.5a.75.75 0 0 0 .75-.75V3.5h.25a.75.75 0 0 0 0-1.5h-7.5ZM3.5 5.5A.5.5 0 0 1 4 5h.5a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-.5.5H4a.5.5 0 0 1-.5-.5v-.5Zm.5 2a.5.5 0 0 0-.5.5v.5A.5.5 0 0 0 4 9h.5a.5.5 0 0 0 .5-.5V8a.5.5 0 0 0-.5-.5H4Zm2-2a.5.5 0 0 1 .5-.5H7a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-.5.5h-.5A.5.5 0 0 1 6 6v-.5Zm.5 2A.5.5 0 0 0 6 8v.5a.5.5 0 0 0 .5.5H7a.5.5 0 0 0 .5-.5V8a.5.5 0 0 0-.5-.5h-.5ZM11.5 6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.75a.75.75 0 0 0 0-1.5H14v-5h.25a.75.75 0 0 0 0-1.5H11.5Zm.5 1.5h.5a.5.5 0 0 1 .5.5v.5a.5.5 0 0 1-.5.5H12a.5.5 0 0 1-.5-.5V8a.5.5 0 0 1 .5-.5Zm0 2.5a.5.5 0 0 0-.5.5v.5a.5.5 0 0 0 .5.5h.5a.5.5 0 0 0 .5-.5v-.5a.5.5 0 0 0-.5-.5H12Z"
clip-rule="evenodd"
/>
</svg>
<span><%= account.organization %></span>
</div>
</span>
<span :if={@allow_unlink_external_provider}>
<button
phx-click="unlink"
phx-value-issuer={account.issuer}
data-confirm={gettext("Are you sure you want to unlink this account?")}
class="font-medium text-red-600 hover:text-red-500"
>
<%= gettext("Unlink") %>
</button>
</span>
</div>
<% end %>
<%= for account <- @lti_accounts do %>
<div class="text-sm text-gray-900 bg-white rounded-md py-2 px-4 shadow-base flex gap-x-2 items-center justify-start mb-2">
<img src="/images/icons/lms.png" class="w-8" />
<span class="flex-grow">
LMS <span class="text-gray-500 text-xs">#<%= account.registration_id %></span>
</span>
<span :if={@allow_unlink_external_provider} class="ml-4 flex-shrink-0">
<button
phx-click="unlink"
phx-value-registration_id={account.registration_id}
data-confirm={gettext("Are you sure you want to unlink this account?")}
class="font-medium text-red-600 hover:text-red-500"
>
<%= gettext("Unlink") %>
</button>
</span>
</div>
<% end %>
</dd>
</div> </div>
</dl> </dl>
</div> </div>
@@ -148,6 +252,7 @@
<ClaperWeb.Component.Input.select <ClaperWeb.Component.Input.select
form={f} form={f}
fieldClass="!w-auto" fieldClass="!w-auto"
labelClass="text-sm font-medium text-gray-500"
array={[ array={[
{"Deutsch", "de"}, {"Deutsch", "de"},
{"English", "en"}, {"English", "en"},
@@ -163,7 +268,7 @@
</dl> </dl>
</div> </div>
</div> </div>
<div> <div :if={!@is_external_user}>
<div class="py-5"> <div class="py-5">
<h3 class="text-lg leading-6 font-medium text-gray-900"> <h3 class="text-lg leading-6 font-medium text-gray-900">
<%= gettext("Danger zone") %> <%= gettext("Danger zone") %>

View File

@@ -117,7 +117,7 @@ defmodule ClaperWeb.Plugs.Locale do
end end
defp fallback_tags(tag, tags) do defp fallback_tags(tag, tags) do
case String.split(tag, "-") do case String.split(tag, "-", parts: 2) do
[language, _country_variant] -> [language, _country_variant] ->
if Enum.member?(tags, language), do: [tag], else: [tag, language] if Enum.member?(tags, language), do: [tag], else: [tag, language]

View File

@@ -14,6 +14,15 @@ defmodule ClaperWeb.Router do
plug(ClaperWeb.Plugs.Locale) plug(ClaperWeb.Plugs.Locale)
end end
pipeline :lti do
plug(:accepts, ["html", "json"])
plug(:put_root_layout, html: {ClaperWeb.LayoutView, :root})
plug(:fetch_session)
plug(:fetch_live_flash)
plug(:fetch_current_user)
plug(ClaperWeb.Plugs.Locale)
end
pipeline :protect_mailbox do pipeline :protect_mailbox do
plug ClaperWeb.MailboxGuard plug ClaperWeb.MailboxGuard
end end
@@ -52,8 +61,7 @@ defmodule ClaperWeb.Router do
live("/users/settings", UserSettingsLive.Show, :show) live("/users/settings", UserSettingsLive.Show, :show)
live("/users/settings/edit/password", UserSettingsLive.Show, :edit_password) live("/users/settings/edit/password", UserSettingsLive.Show, :edit_password)
live("/users/settings/edit/email", UserSettingsLive.Show, :edit_email) live("/users/settings/edit/email", UserSettingsLive.Show, :edit_email)
live("/users/settings/edit/avatar", UserSettingsLive.Show, :edit_avatar) live("/users/settings/set/password", UserSettingsLive.Show, :set_password)
live("/users/settings/edit/fullname", UserSettingsLive.Show, :edit_full_name)
end end
end end
@@ -115,6 +123,21 @@ defmodule ClaperWeb.Router do
post("/users/reset_password", UserResetPasswordController, :create) post("/users/reset_password", UserResetPasswordController, :create)
get("/users/reset_password/:token", UserResetPasswordController, :edit) get("/users/reset_password/:token", UserResetPasswordController, :edit)
post("/users/reset_password/:token", UserResetPasswordController, :update) post("/users/reset_password/:token", UserResetPasswordController, :update)
get("/users/oidc", UserOidcAuth, :new)
get("/users/oidc/callback", UserOidcAuth, :callback)
end
scope "/", ClaperWeb do
pipe_through([:lti])
get("/.well-known/jwks.json", Lti.RegistrationController, :jwks)
get("/lti/register", Lti.RegistrationController, :new)
post("/lti/register", Lti.RegistrationController, :create)
post("/lti/login", Lti.LaunchController, :login)
get("/lti/login", Lti.LaunchController, :login)
post("/lti/launch", Lti.LaunchController, :launch)
get("/lti/grades", Lti.GradeController, :create)
end end
scope "/", ClaperWeb do scope "/", ClaperWeb do

View File

@@ -0,0 +1,21 @@
<div>
<div class="relative h-screen bg-black flex justify-center items-center">
<div class="max-w-xl text-center">
<p class="text-4xl font-bold text-gray-100 flex items-center justify-center gap-x-2">
<span><%= gettext("Oops") %></span>
</p>
<div class="my-8">
<p class="text-gray-200 font-bold">
<%= gettext("You cannot perform this action") %>
</p>
<pre class="text-gray-200 my-4 text-sm bg-gray-800 p-2 rounded-lg"><%= @msg %></pre>
</div>
<button
class="mx-auto mt-8 flex justify-center text-white p-4 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline shadow-lg bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
onclick="window.close()"
>
<%= gettext("Close") %>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,8 @@
<h1>success</h1>
<p>Course label: <%= @course_label %></p>
<p>Course title: <%= @course_title %></p>
<p>Resource title: <%= @resource_title %></p>
<a href={~p"/lti/grades"}>Send grades</a>

View File

@@ -0,0 +1,46 @@
<div>
<div class="relative min-h-screen grid bg-black">
<div class="flex flex-col sm:flex-row items-center md:items-start sm:justify-center md:justify-start flex-auto min-w-0 ">
<div
class="sm:w-1/2 xl:w-3/5 bg-primary-500 h-full hidden md:flex flex-auto items-center justify-center p-10 overflow-hidden text-white bg-no-repeat bg-cover relative"
style="background-image: url(/images/education.jpg); background-position: 0% 60%;"
>
<div class="absolute bg-black opacity-25 inset-0 z-0"></div>
<div class="w-full lg:max-w-2xl md:max-w-md z-10 items-center text-center ">
<div class=" font-bold leading-tight mb-6 mx-auto w-full content-center items-center ">
<img src="/images/logo-white.svg" class="ml-3 w-auto lg:h-20 h-15 inline" />
</div>
</div>
</div>
<div class="md:flex md:items-center md:justify-left w-full sm:w-auto md:h-full xl:w-1/2 p-8 md:p-10 lg:p-14 sm:rounded-lg md:rounded-none ">
<div class="max-w-xl w-full space-y-4">
<div class="lg:text-left text-center">
<div class="mt-6 font-bold text-gray-100 flex items-center gap-x-3">
<img src="/images/logo.svg" class="h-10 w-auto inline" />
<span class="text-2xl font-thin text-white">x</span>
<img src="/images/lms-platforms.png" class="h-12 w-auto inline" />
</div>
<h2 class="mt-6 text-4xl md:text-6xl font-bold text-gray-100">
<%= gettext("Bring Claper to your LMS") %>
</h2>
<p class="mt-10 text-2xl md:text-4xl text-gray-200">
<%= gettext("Register your platform") %>
</p>
</div>
<div class="flex flex-row justify-center items-center space-x-3"></div>
<form action={~p"/lti/register"} method="post">
<input type="hidden" name="openid_configuration" value={@conf} />
<input type="hidden" name="registration_token" value={@token} />
<button
type="submit"
class="flex justify-center text-white p-4 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline shadow-lg bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
>
<%= gettext("Add Claper") %>
</button>
</form>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,30 @@
<div>
<div class="relative h-screen bg-black flex justify-center items-center">
<div class="max-w-xl text-center">
<p class="text-4xl font-bold text-gray-100 flex items-center justify-center gap-x-2">
<span><%= gettext("Registration completed") %></span>
<img src="/images/icons/thumb.svg" class="h-12 -mt-3" />
</p>
<div class="my-8">
<p class="text-gray-200">
<%= gettext("Your next steps") %>:
</p>
<div class="mt-4 bg-gray-800 p-6 rounded-lg shadow-lg border border-white">
<ol class="list-decimal list-inside text-gray-200">
<li class="mb-2"><%= gettext("Activate the tool in your LMS") %></li>
<li class="mb-2"><%= gettext("Configure it to be opened in a new window") %></li>
<li class="mb-2">
<%= gettext("Check the permissions to share name and email of users") %>
</li>
</ol>
</div>
</div>
<button
class="mx-auto mt-8 flex justify-center text-white p-4 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline shadow-lg bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
onclick="(window.opener || window.parent).postMessage({subject:'org.imsglobal.lti.close'}, '*');"
>
<%= gettext("Finish") %>
</button>
</div>
</div>
</div>

View File

@@ -27,30 +27,32 @@
</div> </div>
<div class="flex flex-row justify-center items-center space-x-3"></div> <div class="flex flex-row justify-center items-center space-x-3"></div>
<.form :let={f} for={@conn} action={~p"/users/log_in"} as={:user} class="mt-12 mb-4"> <.form :let={f} for={@conn} action={~p"/users/log_in"} as={:user} class="mt-12">
<%= if @error_message do %> <%= if @error_message do %>
<ClaperWeb.Component.Alert.error message={@error_message} stick={true} /> <ClaperWeb.Component.Alert.error message={@error_message} stick={true} />
<% end %> <% end %>
<input type="hidden" name="remember" value="true" /> <input type="hidden" name="remember" value="true" />
<ClaperWeb.Component.Input.email <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
form={f} <ClaperWeb.Component.Input.email
key={:email} form={f}
labelClass="text-white sr-only" key={:email}
placeholder={gettext("Your email address")} labelClass="text-white sr-only"
fieldClass="bg-gray-700 text-white" placeholder={gettext("Email address")}
name={gettext("Email")} fieldClass="bg-gray-700 text-white"
autofocus="true" name={gettext("Email")}
required="true" autofocus="true"
/> required="true"
<ClaperWeb.Component.Input.password />
form={f} <ClaperWeb.Component.Input.password
key={:password} form={f}
labelClass="text-white sr-only" key={:password}
placeholder={gettext("Your password")} labelClass="text-white sr-only"
fieldClass="bg-gray-700 text-white" placeholder={gettext("Password")}
name={gettext("Password")} fieldClass="bg-gray-700 text-white"
required="true" name={gettext("Password")}
/> required="true"
/>
</div>
<div class="pt-5"> <div class="pt-5">
<button <button
@@ -62,13 +64,24 @@
</div> </div>
</.form> </.form>
<div class="mt-4 text-center flex gap-x-2 justify-center"> <div :if={@oidc_enabled}>
<%= link(
to: ~p"/users/oidc",
class:
"w-full flex justify-center items-center gap-x-2 py-2 px-4 rounded-md shadow-sm text-sm font-medium text-white border-2 border-secondary-500 hover:bg-secondary-500 hover:bg-opacity-40"
) do %>
<img width="24" src={@oidc_logo_url} />
<span><%= gettext("Login with %{provider}", provider: @oidc_provider_name) %></span>
<% end %>
</div>
<div class="mt-4 text-center justify-center">
<%= link(gettext("Forgot your password?"), <%= link(gettext("Forgot your password?"),
to: ~p"/users/reset_password", to: ~p"/users/reset_password",
class: "text-white text-sm text-center" class: "text-white text-sm text-center"
) %> ) %>
<%= if Application.get_env(:claper, :enable_account_creation) do %> <%= if Application.get_env(:claper, :enable_account_creation) do %>
<%= link(gettext("Create account"), <span class="text-white">•</span> <%= link(gettext("Create account"),
to: ~p"/users/register", to: ~p"/users/register",
class: "text-white text-sm text-center" class: "text-white text-sm text-center"
) %> ) %>

View File

@@ -27,7 +27,7 @@ defmodule ClaperWeb.Component.Input do
autocomplete: @key, autocomplete: @key,
value: @value, value: @value,
class: class:
"#{@fieldClass} read-only:opacity-50 outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 focus:ring-2 block w-full text-lg border-gray-300 rounded-md py-4 px-3" "#{@fieldClass} read-only:opacity-50 outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 focus:ring-2 block w-full text-lg border-gray-300 rounded-md py-2 px-3"
) %> ) %>
</div> </div>
<%= if Keyword.has_key?(@form.errors, @key) do %> <%= if Keyword.has_key?(@form.errors, @key) do %>
@@ -60,7 +60,7 @@ defmodule ClaperWeb.Component.Input do
autocomplete: @key, autocomplete: @key,
value: @value, value: @value,
class: class:
"#{@fieldClass} read-only:opacity-50 outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 focus:ring-2 block w-full text-lg border-gray-300 rounded-md py-4 px-3" "#{@fieldClass} read-only:opacity-50 outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 focus:ring-2 block w-full text-lg border-gray-300 rounded-md py-2 px-3"
) %> ) %>
</div> </div>
<%= if Keyword.has_key?(@form.errors, @key) do %> <%= if Keyword.has_key?(@form.errors, @key) do %>
@@ -89,7 +89,7 @@ defmodule ClaperWeb.Component.Input do
placeholder: @placeholder, placeholder: @placeholder,
autocomplete: @key, autocomplete: @key,
class: class:
"#{@fieldClass} outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 block w-full text-lg border-gray-300 rounded-md py-4 px-3" "#{@fieldClass} outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 block w-full text-lg border-gray-300 rounded-md py-2 px-3"
) %> ) %>
</div> </div>
<%= if Keyword.has_key?(@form.errors, @key) do %> <%= if Keyword.has_key?(@form.errors, @key) do %>
@@ -110,7 +110,6 @@ defmodule ClaperWeb.Component.Input do
phx-click={checked(@checked, @key)} phx-click={checked(@checked, @key)}
disabled={@disabled} disabled={@disabled}
phx-value-key={@key} phx-value-key={@key}
id={"check-#{@key}"}
type="button" type="button"
class="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full" class="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full"
role="switch" role="switch"
@@ -122,11 +121,11 @@ defmodule ClaperWeb.Component.Input do
</span> </span>
<span <span
aria-hidden="true" aria-hidden="true"
class={"#{if @checked, do: 'bg-primary-500', else: 'bg-gray-200'} pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out"} class={"#{if @checked, do: "bg-primary-500", else: "bg-gray-200"} pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out"}
> >
</span> </span>
<span <span
class={"#{if @checked, do: 'translate-x-5', else: 'translate-x-0'} pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow ring-0 transition-transform duration-200 ease-in-out"} class={"#{if @checked, do: "translate-x-5", else: "translate-x-0"} pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow ring-0 transition-transform duration-200 ease-in-out"}
aria-hidden="true" aria-hidden="true"
> >
</span> </span>
@@ -177,7 +176,7 @@ defmodule ClaperWeb.Component.Input do
<%= label(@form, @key, @name, class: "block text-sm font-medium text-gray-700") %> <%= label(@form, @key, @name, class: "block text-sm font-medium text-gray-700") %>
<div class="mt-1 relative"> <div class="mt-1 relative">
<img <img
class="icon absolute transition-all top-3 left-2 duration-100" class="icon absolute transition-all top-2.5 left-2 duration-100 h-6"
src="/images/icons/hashtag.svg" src="/images/icons/hashtag.svg"
alt="code" alt="code"
/> />
@@ -188,7 +187,7 @@ defmodule ClaperWeb.Component.Input do
autofocus: @autofocus, autofocus: @autofocus,
autocomplete: @key, autocomplete: @key,
class: class:
"read-only:opacity-50 outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 block w-full text-lg border-gray-300 rounded-md py-4 pr-3 pl-12 uppercase" "read-only:opacity-50 outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 block w-full text-lg border-gray-300 rounded-md py-2 pr-3 pl-9 uppercase"
) %> ) %>
</div> </div>
<%= if Keyword.has_key?(@form.errors, @key) do %> <%= if Keyword.has_key?(@form.errors, @key) do %>
@@ -217,7 +216,7 @@ defmodule ClaperWeb.Component.Input do
placeholder: @placeholder, placeholder: @placeholder,
autocomplete: false, autocomplete: false,
class: class:
"outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 block w-full text-lg border-gray-300 rounded-md py-4 px-3 read-only:opacity-50" "outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 block w-full text-lg border-gray-300 rounded-md py-2 px-3 read-only:opacity-50"
) %> ) %>
</div> </div>
@@ -252,7 +251,7 @@ defmodule ClaperWeb.Component.Input do
autocomplete: @key, autocomplete: @key,
value: @value, value: @value,
class: class:
"#{@fieldClass} read-only:opacity-50 shadow-base block w-full text-lg focus:ring-primary-500 focus:ring-2 outline-none rounded-md py-4 px-3", "#{@fieldClass} read-only:opacity-50 shadow-base block w-full text-lg focus:ring-primary-500 focus:ring-2 outline-none rounded-md py-2 px-3",
"x-model": "input", "x-model": "input",
"x-ref": "input" "x-ref": "input"
) %> ) %>
@@ -283,7 +282,7 @@ defmodule ClaperWeb.Component.Input do
autofocus: @autofocus, autofocus: @autofocus,
placeholder: @placeholder, placeholder: @placeholder,
class: class:
"#{@fieldClass} shadow-base block w-full text-lg focus:ring-primary-500 focus:ring-2 outline-none rounded-md py-4 px-3", "#{@fieldClass} shadow-base block w-full text-lg focus:ring-primary-500 focus:ring-2 outline-none rounded-md py-2 px-3",
"x-model": "input", "x-model": "input",
"x-ref": "input" "x-ref": "input"
) %> ) %>

View File

@@ -0,0 +1,3 @@
defmodule ClaperWeb.Lti.GradeView do
use ClaperWeb, :view
end

View File

@@ -0,0 +1,3 @@
defmodule ClaperWeb.Lti.LaunchView do
use ClaperWeb, :view
end

View File

@@ -0,0 +1,3 @@
defmodule ClaperWeb.Lti.RegistrationView do
use ClaperWeb, :view
end

2
lib/lti_13.ex Normal file
View File

@@ -0,0 +1,2 @@
defmodule Lti13 do
end

37
lib/lti_13/deployments.ex Normal file
View File

@@ -0,0 +1,37 @@
defmodule Lti13.Deployments do
import Ecto.Query, warn: false
alias Claper.Repo
alias Lti13.Deployments.Deployment
@doc """
Creates a deployment.
## Examples
iex> create_deployment(%{deployment_id: 1, registration_id: 1})
{:ok, %Deployment{}}
iex> create_deployment(%{deployment_id: :bad_value, registration_id: 1})
{:error, %Ecto.Changeset{}}
"""
def create_deployment(attrs) do
%Deployment{}
|> Deployment.changeset(attrs)
|> Repo.insert()
end
@doc """
Gets a deployment by registration and deployment id.
## Examples
iex> get_deployment(1, 1)
%Deployment{}
iex> get_deployment(1, :bad_value)
nil
"""
def get_deployment(registration_id, deployment_id) do
Repo.one(
from(r in Deployment,
where: r.registration_id == ^registration_id and r.deployment_id == ^deployment_id
)
)
end
end

View File

@@ -0,0 +1,19 @@
defmodule Lti13.Deployments.Deployment do
use Ecto.Schema
import Ecto.Changeset
schema "lti_13_deployments" do
field :deployment_id, :integer
belongs_to :registration, Lti13.Registrations.Registration
timestamps()
end
@doc false
def changeset(deployment, attrs \\ %{}) do
deployment
|> cast(attrs, [:deployment_id, :registration_id])
|> validate_required([:deployment_id, :registration_id])
end
end

55
lib/lti_13/jwks.ex Normal file
View File

@@ -0,0 +1,55 @@
defmodule Lti13.Jwks do
import Ecto.Query, warn: false
alias Claper.Repo
alias Lti13.Jwks.Jwk
def create_jwk(attrs) do
%Jwk{}
|> Jwk.changeset(attrs)
|> Repo.insert()
end
def get_active_jwk() do
case Repo.all(from(k in Jwk, where: k.active == true, order_by: [desc: k.id], limit: 1)) do
[head | _] -> head
_ -> {:error, %{msg: "No active Jwk found", reason: :not_found}}
end
end
def get_all_jwks() do
Repo.all(from(k in Jwk))
end
def get_jwk_by_registration(%Lti13.Registrations.Registration{tool_jwk_id: tool_jwk_id}) do
Repo.one(
from(jwk in Jwk,
where: jwk.id == ^tool_jwk_id
)
)
end
@doc """
Gets a all public keys.
## Examples
iex> get_all_public_keys()
%{keys: []}
"""
def get_all_public_keys() do
public_keys =
get_all_jwks()
|> Enum.map(fn %{pem: pem, typ: typ, alg: alg, kid: kid} ->
pem
|> JOSE.JWK.from_pem()
|> JOSE.JWK.to_public()
|> JOSE.JWK.to_map()
|> (fn {_kty, public_jwk} -> public_jwk end).()
|> Map.put("typ", typ)
|> Map.put("alg", alg)
|> Map.put("kid", kid)
|> Map.put("use", "sig")
end)
%{keys: public_keys}
end
end

23
lib/lti_13/jwks/jwk.ex Normal file
View File

@@ -0,0 +1,23 @@
defmodule Lti13.Jwks.Jwk do
use Ecto.Schema
import Ecto.Changeset
schema "lti_13_jwks" do
field :pem, :string
field :typ, :string
field :alg, :string
field :kid, :string
field :active, :boolean, default: false
has_many :registrations, Lti13.Registrations.Registration, foreign_key: :tool_jwk_id
timestamps()
end
@doc false
def changeset(jwk, attrs \\ %{}) do
jwk
|> cast(attrs, [:pem, :typ, :alg, :kid, :active])
|> validate_required([:pem, :typ, :alg, :kid])
end
end

View File

@@ -0,0 +1,61 @@
defmodule Lti13.Jwks.Utils.KeyGenerator do
@chars "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|> String.split("", trim: true)
@doc """
Create a random passphrase of size given (defaults to 256)
"""
def passphrase(len \\ 256) do
Enum.map_join(1..len, "", fn _i -> Enum.random(@chars) end)
end
@doc """
Generates RSA public and private key pair to validate between Tool and the Platform
## Examples
iex> generate_key_pair()
%{public_key: "...", private_key: "...", key_id: "..."}
"""
def generate_key_pair do
key_id = passphrase()
{:ok, rsa_priv_key} = generate_key(:rsa, 4096, 65_537)
{:ok, public_key} = public_key_from_private_key(rsa_priv_key)
{:ok, private_key_pem} = pem_entry_encode(rsa_priv_key, :RSAPrivateKey)
{:ok, public_key_pem} = pem_entry_encode(public_key, :RSAPublicKey)
%{public_key: public_key_pem, private_key: private_key_pem, key_id: key_id}
end
defp generate_key(type, bits, public_exp) do
{:ok, :public_key.generate_key({type, bits, public_exp})}
catch
kind, error ->
normalize_error(kind, error, __STACKTRACE__)
end
defp public_key_from_private_key(private_key) do
public_modulus = elem(private_key, 2)
public_exponent = elem(private_key, 3)
{:ok, {:RSAPublicKey, public_modulus, public_exponent}}
end
defp pem_entry_encode(key, type) do
pem_entry = :public_key.pem_entry_encode(type, key)
{:ok, :public_key.pem_encode([pem_entry])}
catch
kind, error ->
normalize_error(kind, error, __STACKTRACE__)
end
defp normalize_error(kind, error, stacktrace) do
case Exception.normalize(kind, error) do
%{message: message} ->
{:error, message}
x ->
{kind, x, stacktrace}
end
end
end

View File

@@ -0,0 +1,210 @@
defmodule Lti13.Jwks.Validator do
def registration_key_set_url(%{key_set_url: key_set_url}) do
{:ok, key_set_url}
end
def extract_param(params, name) do
case params[name] do
nil ->
{:error, %{reason: :missing_param, msg: "Missing #{name}"}}
param ->
{:ok, param}
end
end
def peek_header(jwt_string) do
case Joken.peek_header(jwt_string) do
{:ok, header} ->
{:ok, header}
{:error, reason} ->
{:error, %{reason: reason, msg: "Invalid JWT"}}
end
end
def peek_claims(jwt_string) do
case Joken.peek_claims(jwt_string) do
{:ok, claims} ->
{:ok, claims}
{:error, reason} ->
{:error, %{reason: reason, msg: "Invalid JWT"}}
end
end
def peek_jwt_kid(jwt_string) do
with {:ok, jwt_body} <- peek_header(jwt_string) do
{:ok, jwt_body["kid"]}
end
end
def validate_jwt_signature(jwt_string, key_set_url) do
with {:ok, kid} <- peek_jwt_kid(jwt_string),
{:ok, public_key} <- fetch_public_key(key_set_url, kid) do
{_kty, pk} = JOSE.JWK.to_map(public_key)
signer = Joken.Signer.create("RS256", pk)
case Joken.verify_and_validate(%{}, jwt_string, signer) do
{:ok, jwt} ->
{:ok, jwt}
{:error, reason} ->
{:error, %{reason: reason, msg: "Invalid JWT"}}
end
end
end
def validate_timestamps(jwt) do
current_time = DateTime.utc_now() |> DateTime.to_unix()
exp = Map.get(jwt, "exp")
iat = Map.get(jwt, "iat")
nbf = Map.get(jwt, "nbf")
cond do
exp && current_time > exp ->
{:error, %{reason: :invalid_jwt_timestamp, msg: "JWT exp is expired"}}
iat && current_time < iat ->
{:error, %{reason: :invalid_jwt_timestamp, msg: "JWT iat is invalid"}}
nbf && current_time < nbf ->
{:error, %{reason: :invalid_jwt_timestamp, msg: "JWT nbf is invalid"}}
true ->
{:ok}
end
end
@spec validate_nonce(
Claper.Accounts.User.t(),
map(),
String.t()
) :: {:ok} | {:error, %{msg: any(), reason: :invalid_nonce}}
def validate_nonce(user, jwt, domain) do
case Lti13.Nonces.create_nonce(%{value: jwt["nonce"], domain: domain, lti_user_id: user.id}) do
{:ok, _nonce} ->
{:ok}
{:error, changeset} ->
{:error, %{reason: :invalid_nonce, msg: changeset}}
end
end
def validate_user(
%{
"sub" => sub,
"name" => name,
"email" => email,
"https://purl.imsglobal.org/spec/lti/claim/roles" => roles
},
registration
) do
case Lti13.Users.get_or_create_user(%{
sub: sub,
name: name,
email: email,
roles: roles,
registration_id: registration.id
}) do
{:error, _} ->
{:error, %{reason: :invalid_user, msg: "Invalid user"}}
{:ok, user} ->
{:ok, user}
end
end
def validate_issuer(jwt, issuer) do
if jwt["iss"] == issuer do
{:ok}
else
{:error,
%{
reason: :invalid_issuer,
msg: "Issuer ('iss' claim) in JWT doesn't match the expected issuer"
}}
end
end
def validate_audience(jwt, audience) do
audience_claims = String.split(jwt["aud"], ",", trim: true)
if audience_claims in audience do
{:ok}
else
{:error,
%{
reason: :invalid_issuer,
msg: "Audience ('aud' claim) in JWT doesn't contain the expected audience"
}}
end
end
def fetch_public_key(key_set_url, kid) do
public_key_set =
case Req.get(key_set_url) do
{:ok, %Req.Response{status: 200, body: body}} -> body
error -> error
end
find_and_process_key(public_key_set, kid)
end
defp find_and_process_key(public_key_set, kid) do
if container?(public_key_set) do
find_key_in_set(public_key_set, kid)
else
return_key_not_found(kid)
end
end
defp find_key_in_set(public_key_set, kid) do
case Enum.find(public_key_set["keys"], fn key -> container?(key) && key["kid"] == kid end) do
nil -> return_key_not_found(kid)
public_key_json -> process_public_key(public_key_json)
end
end
defp process_public_key(public_key_json) do
public_key =
public_key_json
|> convert_map_to_base64url()
|> JOSE.JWK.from()
{:ok, public_key}
end
defp container?(container) do
Keyword.keyword?(container) || is_map(container) || is_struct(container)
end
defp return_key_not_found(kid) do
{:error,
%{
reason: :key_not_found,
msg: "Key with kid #{kid} not found in the fetched list of public keys"
}}
end
@doc """
Given a map representing a JWK, encodes all its values to Base64URL.
"""
@spec convert_map_to_base64url(map()) :: map()
def convert_map_to_base64url(key_map) do
for {k, v} <- key_map,
into: %{},
do: {k, to_base64url(v)}
end
defp to_base64url(value) when is_binary(value) do
case Base.decode64(value, padding: false) do
:error -> value
{:ok, decoded} -> Base.url_encode64(decoded, padding: false)
end
end
defp to_base64url(value), do: value
end

27
lib/lti_13/nonces.ex Normal file
View File

@@ -0,0 +1,27 @@
defmodule Lti13.Nonces do
import Ecto.Query, warn: false
alias Claper.Repo
alias Lti13.Nonces.Nonce
def get_nonce(value, domain \\ nil) do
case domain do
nil ->
Repo.get_by(Nonce, value: value)
domain ->
Repo.get_by(Nonce, value: value, domain: domain)
end
end
def create_nonce(attrs) do
%Nonce{}
|> Nonce.changeset(attrs)
|> Repo.insert()
end
# 86400 seconds = 24 hours
def delete_expired_nonces(nonce_ttl_sec \\ 86_400) do
nonce_expiry = DateTime.utc_now() |> DateTime.add(-nonce_ttl_sec, :second)
Repo.delete_all(from(n in Nonce, where: n.inserted_at < ^nonce_expiry))
end
end

View File

@@ -0,0 +1,20 @@
defmodule Lti13.Nonces.Nonce do
use Ecto.Schema
import Ecto.Changeset
schema "lti_13_nonces" do
field :value, :string
field :domain, :string
belongs_to :lti_user, Lti13.Users.User, foreign_key: :lti_user_id
timestamps()
end
@doc false
def changeset(nonce, attrs) do
nonce
|> cast(attrs, [:value, :domain, :lti_user_id])
|> validate_required([:value])
|> unique_constraint(:value, name: :value_domain_index)
end
end

View File

@@ -0,0 +1,40 @@
defmodule Lti13.Registrations do
import Ecto.Query, warn: false
alias Lti13.Deployments.Deployment
alias Claper.Repo
alias Lti13.Registrations.Registration
def create_registration(attrs) do
%Registration{}
|> Registration.changeset(attrs)
|> Repo.insert()
end
def get_registration_deployment(issuer, client_id, deployment_id) do
case Repo.one(
from(d in Deployment,
join: r in Registration,
on: d.registration_id == r.id,
where:
r.issuer == ^issuer and r.client_id == ^client_id and
d.deployment_id == ^deployment_id,
select: {r, d}
)
) do
nil ->
nil
{r, d} ->
{r, d}
end
end
def get_registration_by_issuer_client_id(issuer, client_id) do
Repo.one(
from(registration in Registration,
where: registration.issuer == ^issuer and registration.client_id == ^client_id,
select: registration
)
)
end
end

View File

@@ -0,0 +1,54 @@
defmodule Lti13.Registrations.Registration do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
issuer: String.t() | nil,
client_id: String.t() | nil,
key_set_url: String.t() | nil,
auth_token_url: String.t() | nil,
auth_login_url: String.t() | nil,
auth_server: String.t() | nil,
tool_jwk_id: integer() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "lti_13_registrations" do
field :issuer, :string
field :client_id, :string
field :key_set_url, :string
field :auth_token_url, :string
field :auth_login_url, :string
field :auth_server, :string
has_many :deployments, Lti13.Deployments.Deployment
belongs_to :tool_jwk, Lti13.Jwks.Jwk, foreign_key: :tool_jwk_id
timestamps()
end
@doc false
def changeset(registration, attrs \\ %{}) do
registration
|> cast(attrs, [
:issuer,
:client_id,
:key_set_url,
:auth_token_url,
:auth_login_url,
:auth_server,
:tool_jwk_id
])
|> validate_required([
:issuer,
:client_id,
:key_set_url,
:auth_token_url,
:auth_login_url,
:auth_server,
:tool_jwk_id
])
end
end

60
lib/lti_13/resources.ex Normal file
View File

@@ -0,0 +1,60 @@
defmodule Lti13.Resources do
import Ecto.Query, warn: false
alias Claper.Repo
alias Lti13.Resources.Resource
def create_resource(attrs) do
%Resource{}
|> Resource.changeset(attrs)
|> Repo.insert()
end
def get_resource_by_id_and_registration(resource_id, registration_id) do
from(r in Resource,
where: r.resource_id == ^resource_id and r.registration_id == ^registration_id
)
|> Repo.one()
|> case do
nil -> nil
resource -> resource |> Repo.preload(:event)
end
end
@doc """
Creates a resource and event with the given title and resource_id
## Examples
iex> create_resource_with_event(%{title: "Test", resource_id: "123", lti_user: %Lti13.Users.User{}})
{:ok, %Claper.Events.Event{}, %Lti13.Resources.Resource{}}
iex> create_resource_with_event(%{})
{:error, %{reason: :invalid_resource, msg: "Failed to create resource"}}
"""
@spec create_resource_with_event(map()) ::
{:ok, Resource.t()} | {:error, map()}
def create_resource_with_event(%{title: title, resource_id: resource_id, lti_user: lti_user}) do
with {:ok, event} <-
Claper.Events.create_event(%{
name: title,
code:
:crypto.strong_rand_bytes(10) |> Base.encode16(case: :lower) |> binary_part(0, 6),
user_id: lti_user.user_id,
started_at: NaiveDateTime.utc_now(),
presentation_file: %{
"status" => "done",
"length" => 0,
"presentation_state" => %{}
}
}),
{:ok, resource} <-
create_resource(%{
title: title,
resource_id: resource_id,
event_id: event.id,
registration_id: lti_user.registration_id
}) do
{:ok, resource |> Map.put(:event, event)}
else
{:error, _} -> {:error, %{reason: :invalid_resource, msg: "Failed to create resource"}}
end
end
end

View File

@@ -0,0 +1,31 @@
defmodule Lti13.Resources.Resource do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
title: String.t() | nil,
resource_id: integer() | nil,
event_id: integer(),
registration_id: integer(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "lti_13_resources" do
field :title, :string
field :resource_id, :integer
belongs_to :event, Claper.Events.Event
belongs_to :registration, Lti13.Registrations.Registration
timestamps()
end
@doc false
def changeset(registration, attrs \\ %{}) do
registration
|> cast(attrs, [:title, :resource_id, :event_id, :registration_id])
|> validate_required([:title, :resource_id, :event_id, :registration_id])
end
end

View File

@@ -0,0 +1,213 @@
defmodule Lti13.Tool.LaunchValidation do
import Lti13.Jwks.Validator
alias Lti13.Deployments
alias Lti13.Registrations
@message_validators [
Lti13.Tool.MessageValidators.ResourceMessageValidator
]
@authorized_to_create_event_roles [
"http://purl.imsglobal.org/vocab/lis/v2/membership#Administrator",
"http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor"
]
@doc """
Validates an incoming LTI 1.3 launch and returns the claims if successful.
"""
def validate(params, session_state, _opts \\ []) do
with {:ok} <- validate_oidc_state(params, session_state),
{:ok, registration} <- validate_registration(params),
{:ok, key_set_url} <- registration_key_set_url(registration),
{:ok, id_token} <- extract_param(params, "id_token"),
{:ok, jwt_body} <- validate_jwt_signature(id_token, key_set_url),
{:ok} <- validate_timestamps(jwt_body),
{:ok} <- validate_deployment(registration, jwt_body),
{:ok} <- validate_message(jwt_body),
{:ok, lti_user} <- validate_user(jwt_body, registration),
{:ok} <- validate_nonce(lti_user, jwt_body, "validate_launch"),
{:ok, is_instructor} <- validate_role(jwt_body),
{:ok, resource} <- validate_resource(jwt_body, lti_user, registration, is_instructor),
claims <- jwt_body do
{:ok, %{claims: claims, lti_user: lti_user, resource: resource}}
end
end
# Validate that the state sent with an OIDC launch matches the state that was sent in the OIDC response
# returns a boolean on whether it is valid or not
defp validate_oidc_state(params, session_state) do
case session_state do
nil ->
{:error,
%{
reason: :invalid_oidc_state,
msg:
"State from session is missing. Make sure cookies are enabled and configured correctly"
}}
_ ->
compare_oidc_states(params["state"], session_state)
end
end
defp compare_oidc_states(nil, _),
do: {:error, %{reason: :invalid_oidc_state, msg: "State from OIDC request is missing"}}
defp compare_oidc_states(request_state, session_state) when request_state == session_state,
do: {:ok}
defp compare_oidc_states(_, _),
do:
{:error,
%{reason: :invalid_oidc_state, msg: "State from OIDC request does not match session"}}
defp validate_registration(params) do
with {:ok, issuer, client_id} <- peek_issuer_client_id(params) do
case Registrations.get_registration_by_issuer_client_id(issuer, client_id) do
nil ->
{:error,
%{
reason: :invalid_registration,
msg:
"Registration with issuer \"#{issuer}\" and client id \"#{client_id}\" not found",
issuer: issuer,
client_id: client_id
}}
registration ->
{:ok, registration}
end
end
end
@spec validate_resource(
map(),
Lti13.Users.User.t(),
Lti13.Registrations.Registration.t(),
boolean()
) :: {:ok, Lti13.Resources.Resource.t()} | {:error, map()}
defp validate_resource(
%{
"https://purl.imsglobal.org/spec/lti/claim/custom" => %{
"resource_title" => title,
"resource_id" => resource_id
}
},
lti_user,
registration,
is_instructor
) do
case Lti13.Resources.get_resource_by_id_and_registration(resource_id, registration.id) do
nil -> handle_missing_resource(title, resource_id, lti_user, is_instructor)
resource -> handle_existing_resource(resource, lti_user, is_instructor)
end
end
defp handle_missing_resource(title, resource_id, lti_user, true) do
case Lti13.Resources.create_resource_with_event(%{
title: title,
resource_id: resource_id,
lti_user: lti_user
}) do
{:ok, resource} -> {:ok, resource}
{:error, _} -> {:error, %{reason: :invalid_resource, msg: "Failed to create resource"}}
end
end
defp handle_missing_resource(_, _, _, false),
do: {:error, %{reason: :invalid_resource, msg: "User is not authorized to create resource"}}
defp handle_existing_resource(resource, lti_user, true) do
maybe_create_activity_leader(resource, lti_user)
{:ok, resource}
end
defp handle_existing_resource(resource, _, false), do: {:ok, resource}
defp maybe_create_activity_leader(resource, lti_user) do
activity_leaders = Claper.Events.get_activity_leaders_for_event(resource.event_id)
activity_leaders_emails = Enum.map(activity_leaders, fn al -> al.email end)
if lti_user.email not in activity_leaders_emails && resource.event.user_id != lti_user.user_id do
Claper.Events.create_activity_leader(%{
email: lti_user.email,
user_id: lti_user.id,
event_id: resource.event_id
})
end
end
defp validate_role(jwt) do
roles = jwt["https://purl.imsglobal.org/spec/lti/claim/roles"]
is_instructor = Enum.any?(roles, fn role -> role in @authorized_to_create_event_roles end)
{:ok, is_instructor}
end
defp peek_issuer_client_id(params) do
with {:ok, jwt_string} <- extract_param(params, "id_token"),
{:ok, jwt_claims} <- peek_claims(jwt_string) do
{:ok, jwt_claims["iss"], peek_client_id(jwt_claims["aud"])}
end
end
defp peek_client_id([client_id | _]), do: client_id
defp peek_client_id(client_id), do: client_id
defp validate_deployment(registration, jwt_body) do
deployment_id = jwt_body["https://purl.imsglobal.org/spec/lti/claim/deployment_id"]
deployment = Deployments.get_deployment(registration.id, deployment_id)
case deployment do
nil ->
{:error,
%{
reason: :invalid_deployment,
msg: "Deployment with id \"#{deployment_id}\" not found",
registration_id: registration.id,
deployment_id: deployment_id
}}
_deployment ->
{:ok}
end
end
defp validate_message(jwt_body) do
case jwt_body["https://purl.imsglobal.org/spec/lti/claim/message_type"] do
nil ->
{:error, %{reason: :invalid_message_type, msg: "Missing message type"}}
message_type ->
validate_message_type(jwt_body, message_type)
end
end
defp validate_message_type(jwt_body, message_type) do
case apply_message_validator(jwt_body) do
nil ->
{:error,
%{
reason: :invalid_message_type,
msg: "Invalid or unsupported message type \"#{message_type}\""
}}
{:error, error} ->
{:error,
%{
reason: :invalid_message,
msg: "Message validation failed: (\"#{message_type}\") #{error}"
}}
_ ->
{:ok}
end
end
defp apply_message_validator(jwt_body) do
case Enum.find(@message_validators, fn mv -> mv.can_validate(jwt_body) end) do
nil -> nil
validator -> validator.validate(jwt_body)
end
end
end

View File

@@ -0,0 +1,7 @@
defprotocol Lti_1p3.Tool.MessageValidator do
@spec can_validate(any) :: boolean
def can_validate(jwt_body)
@spec validate(any) :: {:ok} | {:error, String.t()}
def validate(jwt_body)
end

View File

@@ -0,0 +1,54 @@
defmodule Lti13.Tool.MessageValidators.ResourceMessageValidator do
def can_validate(jwt_body) do
jwt_body["https://purl.imsglobal.org/spec/lti/claim/message_type"] == "LtiResourceLinkRequest"
end
def validate(jwt_body) do
with {:ok} <- user_sub(jwt_body),
{:ok} <- lti_version(jwt_body),
{:ok} <- roles_claim(jwt_body),
{:ok} <- resource_link_id(jwt_body) do
{:ok}
else
{:error, error} -> {:error, error}
end
end
defp user_sub(jwt_body) do
case jwt_body["sub"] do
nil ->
{:error, "Must have a user (sub)"}
_ ->
{:ok}
end
end
defp lti_version(jwt_body) do
if jwt_body["https://purl.imsglobal.org/spec/lti/claim/version"] != "1.3.0" do
{:error, "Incorrect version, expected 1.3.0"}
else
{:ok}
end
end
defp roles_claim(jwt_body) do
case jwt_body["https://purl.imsglobal.org/spec/lti/claim/roles"] do
nil ->
{:error, "Missing Roles Claim"}
_ ->
{:ok}
end
end
defp resource_link_id(jwt_body) do
case jwt_body["https://purl.imsglobal.org/spec/lti/claim/resource_link"]["id"] do
nil ->
{:error, "Missing Resource Link Id"}
_ ->
{:ok}
end
end
end

View File

@@ -0,0 +1,86 @@
defmodule Lti13.Tool.OidcLogin do
alias Lti13.Registrations
def oidc_login_redirect_url(params) do
with {:ok, _issuer, login_hint, registration} <- validate_oidc_login(params) do
# craft OIDC auth response
# create unique state. Be sure to add this state to conn
#
# ## Example:
# conn = conn
# |> put_session("state", state)
state = UUID.uuid4()
query_params = %{
"scope" => "openid",
"response_type" => "id_token",
"response_mode" => "form_post",
"prompt" => "none",
"client_id" => params["client_id"],
"redirect_uri" => params["target_link_uri"],
"state" => state,
"nonce" => UUID.uuid4(),
"login_hint" => login_hint
}
# pass back LTI message hint if given
query_params =
case params["lti_message_hint"] do
nil -> query_params
lti_message_hint -> Map.put_new(query_params, "lti_message_hint", lti_message_hint)
end
redirect_url = registration.auth_login_url <> "?" <> URI.encode_query(query_params)
{:ok, state, redirect_url}
end
end
defp validate_oidc_login(params) do
with {:ok, issuer} <- validate_issuer(params),
{:ok, login_hint} <- validate_login_hint(params),
{:ok, registration} <- validate_registration(params) do
{:ok, issuer, login_hint, registration}
end
end
defp validate_issuer(params) do
case params["iss"] do
nil -> {:error, %{reason: :missing_issuer, msg: "Request does not have an issuer (iss)"}}
issuer -> {:ok, issuer}
end
end
defp validate_login_hint(params) do
case params["login_hint"] do
nil ->
{:error,
%{reason: :missing_login_hint, msg: "Request does not have a login hint (login_hint)"}}
login_hint ->
{:ok, login_hint}
end
end
defp validate_registration(params) do
issuer = params["iss"]
client_id = params["client_id"]
lti_deployment_id = params["lti_deployment_id"]
case Registrations.get_registration_by_issuer_client_id(issuer, client_id) do
nil ->
{:error,
%{
reason: :invalid_registration,
msg: "Registration with issuer \"#{issuer}\" and client id \"#{client_id}\" not found",
issuer: issuer,
client_id: client_id,
lti_deployment_id: lti_deployment_id
}}
registration ->
{:ok, registration}
end
end
end

View File

@@ -0,0 +1,119 @@
defmodule Lti13.Tool.Services.AccessToken do
alias Lti13.Jwks
use Joken.Config
@enforce_keys [:access_token, :token_type, :expires_in, :scope]
defstruct [:access_token, :token_type, :expires_in, :scope]
@type t() :: %__MODULE__{
access_token: String.t(),
token_type: String.t(),
expires_in: integer(),
scope: String.t()
}
require Logger
@doc """
Requests an OAuth2 access token. Returns {:ok, %AccessToken{}} on success, {:error, error}
otherwise.
As parameters, expects:
1. The registration from which an access token is being requested
2. A list of scopes being requested
3. The host name of this instance of Torus
## Examples
iex> fetch_access_token(registration, scopes, host)
{:ok,
%Lti13.Tool.Services.AccessToken{
"scope" => "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
"access_token" => "actual_access_token",
"token_type" => "Bearer",
"expires_in" => "3600"
}
}
iex> fetch_access_token(bad_tool)
{:error, "invalid_scope"}
"""
def fetch_access_token(
%{auth_token_url: auth_token_url, client_id: client_id, auth_server: auth_audience},
scopes,
_host
) do
client_assertion =
create_client_assertion(%{
auth_token_url: auth_token_url,
client_id: client_id,
auth_aud: auth_audience
})
request_token(auth_token_url, client_assertion, scopes)
end
defp request_token(url, client_assertion, scopes) do
body =
[
grant_type: "client_credentials",
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: client_assertion,
scope: Enum.join(scopes, " ")
]
|> URI.encode_query()
headers = %{"Content-Type" => "application/x-www-form-urlencoded"}
Logger.debug("Fetching access token with the following parameters")
Logger.debug("client_assertion: #{inspect(client_assertion)}")
Logger.debug("scopes #{inspect(scopes)}")
with {:ok, %Req.Response{status: 200, body: body}} <-
Req.post(url, body: body, headers: headers),
{:ok, result} <- body do
{:ok,
%__MODULE__{
access_token: Map.get(result, "access_token"),
token_type: Map.get(result, "token_type"),
expires_in: Map.get(result, "expires_in"),
scope: Map.get(result, "scope")
}}
else
e ->
Logger.error("Error encountered fetching access token #{inspect(e)}")
{:error, "Error fetching access token"}
end
end
defp create_client_assertion(%{
auth_token_url: auth_token_url,
client_id: client_id,
auth_aud: auth_audience
}) do
# Get the active private key
active_jwk = Jwks.get_active_jwk()
# Sign and return the JWT, include the kid of the key we are using
# in the header.
custom_header = %{"kid" => active_jwk.kid}
signer = Joken.Signer.create("RS256", %{"pem" => active_jwk.pem}, custom_header)
# define our custom claims
custom_claims = %{
"iss" => client_id,
"aud" => audience(auth_token_url, auth_audience),
"sub" => client_id
}
{:ok, token, _} = generate_and_sign(custom_claims, signer)
token
end
defp audience(auth_token_url, nil), do: auth_token_url
defp audience(auth_token_url, ""), do: auth_token_url
defp audience(_auth_token_url, auth_audience), do: auth_audience
end

View File

@@ -0,0 +1,308 @@
defmodule Lti13.Tool.Services.AGS do
@moduledoc """
Implementation of LTI Assignment and Grading Services (AGS) version 2.0.
For information on the standard, see:
https://www.imsglobal.org/spec/lti-ags/v2p0/
"""
@lti_ags_claim_url "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint"
@lineitem_scope_url "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"
@scores_scope_url "https://purl.imsglobal.org/spec/lti-ags/scope/score"
alias Lti13.Tool.Services.AGS.Score
alias Lti13.Tool.Services.AGS.LineItem
alias Lti13.Tool.Services.AccessToken
require Logger
@doc """
Post a score to an existing line item, using an already acquired access token.
"""
def post_score(%Score{} = score, %LineItem{} = line_item, %AccessToken{} = access_token) do
Logger.info("Posting score for user #{score.userId} for line item '#{line_item.label}'")
body = score |> Jason.encode!()
case Req.post(
build_url_with_path(line_item.id, "scores"),
body: body,
headers: score_headers(access_token)
) do
{:ok, %Req.Response{status: code, body: body}} when code in [200, 201] ->
{:ok, body}
e ->
Logger.error(
"Error encountered posting score for user #{score.userId} for line item '#{line_item.label}' #{inspect(e)}"
)
{:error, "Error posting score"}
end
end
@doc """
Creates a line item for a resource id, if one does not exist. Whether or not the
line item is created or already exists, this function returns a line item struct wrapped
in a {:ok, line_item} tuple. On error, returns a {:error, error} tuple.
"""
def fetch_or_create_line_item(
line_items_service_url,
resource_id,
maximum_score_provider,
label,
%AccessToken{} = access_token
) do
Logger.info("fetch_or_create_line_item #{resource_id} #{label}")
# Grade pass back 2.0 line items endpoint allows a GET request with a query
# param filter. We use that to request only the line item that corresponds
# to this particular resource_id. "resource_id", from grade pass back 2.0
# perspective is simply an identifier that the tool uses for a line item and its use
# here as a Torus "resource_id" is strictly coincidence.
prefixed_resource_id = LineItem.to_resource_id(resource_id)
request_url =
build_url_with_params(line_items_service_url, "resource_id=#{prefixed_resource_id}&limit=1")
Logger.info("fetch_or_create_line_item: URL #{request_url}")
with {:ok, %Req.Response{status: code, body: body}} when code in [200, 201] <-
Req.get(request_url, headers: headers(access_token)),
{:ok, result} <- Jason.decode(body) do
case result do
[] ->
Logger.info("fetch_or_create_line_item #{resource_id} #{label}")
create_line_item(
line_items_service_url,
resource_id,
maximum_score_provider.(),
label,
access_token
)
# it is important to match against a possible array of items, in case an LMS does
# not properly support the limit parameter
[raw_line_item | _] ->
Logger.info(
"fetch_or_create_line_item: Retrieved raw line item #{inspect(raw_line_item)} for #{resource_id} #{label}"
)
line_item = to_line_item(raw_line_item)
if line_item.label != label do
update_line_item(line_item, %{label: label}, access_token)
else
{:ok, line_item}
end
end
else
e ->
Logger.error(
"Error encountered fetching line item for #{resource_id} #{label}: #{inspect(e)}"
)
{:error, "Error retrieving existing line items"}
end
end
defp to_line_item(raw_line_item) do
%LineItem{
id: Map.get(raw_line_item, "id"),
scoreMaximum: Map.get(raw_line_item, "scoreMaximum"),
resourceId: Map.get(raw_line_item, "resourceId"),
label: Map.get(raw_line_item, "label")
}
end
def fetch_line_items(line_items_service_url, %AccessToken{} = access_token) do
Logger.info("Fetch line items from #{line_items_service_url}")
# Unfortunately, at least Canvas implements a default limit of 10 line items
# when one makes a request without a 'limit' parameter specified. Setting it explicitly to 1000
# bypasses this default limit, of course, and works in all cases until a course more than
# a thousand grade book entries.
url = build_url_with_params(line_items_service_url, "limit=1000")
with {:ok, %Req.Response{status: 200, body: body}} <-
Req.get(url, headers: headers(access_token)),
{:ok, results} <- Jason.decode(body) do
{:ok, Enum.map(results, fn r -> to_line_item(r) end)}
else
e ->
Logger.error("Error encountered fetching line items from #{url} #{inspect(e)}")
{:error, "Error retrieving all line items"}
end
end
@doc """
Creates a line item for a resource id. This function returns a line item struct wrapped
in a {:ok, line_item} tuple. On error, returns a {:error, error} tuple.
"""
def create_line_item(
line_items_service_url,
resource_id,
score_maximum,
label,
%AccessToken{} = access_token
) do
Logger.info("Create line item for #{resource_id} #{label}")
line_item = %LineItem{
scoreMaximum: score_maximum,
resourceId: LineItem.to_resource_id(resource_id),
label: label
}
body = line_item |> Jason.encode!()
with {:ok, %Req.Response{status: code, body: body}} when code in [200, 201] <-
Req.post(line_items_service_url, body: body, headers: headers(access_token)),
{:ok, result} <- Jason.decode(body) do
{:ok, to_line_item(result)}
else
e ->
Logger.error(
"Error encountered creating line item for #{resource_id} #{label}: #{inspect(e)}"
)
{:error, "Error creating new line item"}
end
end
@doc """
Updates an existing line item. On success returns
a {:ok, line_item} tuple. On error, returns a {:error, error} tuple.
"""
def update_line_item(%LineItem{} = line_item, changes, %AccessToken{} = access_token) do
Logger.info("Updating line item #{line_item.id} for changes #{inspect(changes)}")
updated_line_item = %LineItem{
id: line_item.id,
scoreMaximum: Map.get(changes, :scoreMaximum, line_item.scoreMaximum),
resourceId: line_item.resourceId,
label: Map.get(changes, :label, line_item.label)
}
body = updated_line_item |> Jason.encode!()
# The line_item endpoint defines a PUT operation to update existing line items. The
# url to use is the id of the line item
url = line_item.id
with {:ok, %Req.Response{status: 200, body: body}} <-
Req.put(url, body: body, headers: headers(access_token)),
{:ok, result} <- body do
{:ok, to_line_item(result)}
else
e ->
Logger.error(
"Error encountered updating line item #{line_item.id} for changes #{inspect(changes)}: #{inspect(e)}"
)
{:error, "Error updating existing line item"}
end
end
@doc """
Returns true if grade pass back service is enabled with the necessary scopes. The
necessary scopes are the line item scope to read all line items and create new ones
and the scores scope, to be able to post new scores. Also verifies that the line items
endpoint is present.
"""
def grade_passback_enabled?(lti_launch_params) do
case Map.get(lti_launch_params, @lti_ags_claim_url) do
nil ->
false
config ->
Map.has_key?(config, "lineitems") and has_scope?(config, @lineitem_scope_url) and
has_scope?(config, @scores_scope_url)
end
end
@doc """
Returns the line items URL from LTI launch params.
If not present returns nil.
If a registration is present, uses the auth server domain + the line items path.
"""
def get_line_items_url(lti_launch_params, registration \\ %{}) do
line_items_url =
lti_launch_params
|> Map.get(@lti_ags_claim_url, %{})
|> Map.get("lineitems")
unless is_nil(line_items_url) do
%URI{path: line_items_path} = URI.parse(line_items_url)
registration
|> get_line_items_domain(line_items_url)
|> URI.parse()
|> Map.put(:path, line_items_path)
|> URI.to_string()
end
end
defp get_line_items_domain(%{line_items_service_domain: domain}, default)
when is_nil(domain) or domain == "",
do: default
defp get_line_items_domain(%{line_items_service_domain: domain}, _default), do: domain
defp get_line_items_domain(_registration, default), do: default
@doc """
Returns true if the LTI AGS claim has a particular scope url, false if it does not.
"""
def has_scope?(lti_ags_claim, scope_url) do
case Map.get(lti_ags_claim, "scope", [])
|> Enum.find(nil, fn url -> scope_url == url end) do
nil -> false
_ -> true
end
end
@doc """
Returns the required scopes for the AGS service.
"""
def required_scopes() do
[
@lineitem_scope_url,
@scores_scope_url
]
end
# ---------------------------------------------------------
# Helpers to build headers correctly
defp headers(%AccessToken{} = access_token) do
[
{"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"},
{"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}
] ++ access_token_header(access_token.access_token)
end
defp score_headers(%AccessToken{} = access_token) do
[{"Content-Type", "application/vnd.ims.lis.v1.score+json"}] ++
access_token_header(access_token.access_token)
end
defp access_token_header(access_token),
do: [{"Authorization", "Bearer #{access_token}"}]
# ---------------------------------------------------------
# Helpers to build urls correctly (if base url contian query params)
defp build_url_with_path(base_url, path_to_add) do
case String.split(base_url, "?") do
[base_url, query_params] -> "#{base_url}/#{path_to_add}?#{query_params}"
_ -> "#{base_url}/#{path_to_add}"
end
end
defp build_url_with_params(base_url, params_to_add) do
case String.split(base_url, "?") do
[base_url, query_params] -> "#{base_url}?#{query_params}&#{params_to_add}"
_ -> "#{base_url}?#{params_to_add}"
end
end
end

View File

@@ -0,0 +1,25 @@
defmodule Lti13.Tool.Services.AGS.LineItem do
@derive {Jason.Encoder, except: [:id]}
@enforce_keys [:scoreMaximum, :label, :resourceId]
defstruct [:id, :scoreMaximum, :label, :resourceId]
# The javascript naming convention here is important to match what the
# LTI AGS standard expects
@type t() :: %__MODULE__{
id: String.t(),
scoreMaximum: float,
label: String.t(),
resourceId: String.t()
}
def parse_resource_id(%__MODULE__{} = line_item) do
case line_item.resourceId do
resource_id -> resource_id
end
end
def to_resource_id(resource_id) do
Integer.to_string(resource_id)
end
end

Some files were not shown because too many files have changed in this diff Show More