mirror of
https://github.com/ClaperCo/Claper.git
synced 2025-12-16 11:57:58 +01:00
Version 2.1.0
This commit is contained in:
13
.env.sample
13
.env.sample
@@ -36,4 +36,17 @@ MAIL_FROM_NAME=Claper
|
|||||||
# Claper configuration
|
# Claper configuration
|
||||||
|
|
||||||
#ENABLE_ACCOUNT_CREATION=true
|
#ENABLE_ACCOUNT_CREATION=true
|
||||||
|
#ALLOW_UNLINK_EXTERNAL_PROVIDER=false
|
||||||
|
#LOGOUT_REDIRECT_URL=https://google.com
|
||||||
#GS_JPG_RESOLUTION=300x300
|
#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
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
147
LICENSE.txt
147
LICENSE.txt
@@ -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>.
|
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -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://x.com/alxlion_)
|
[](https://github.com/ClaperCo/Claper)
|
||||||
|
|
||||||
Project Link: [https://github.com/ClaperCo/Claper](https://github.com/ClaperCo/Claper)
|
[](https://discord.gg/M7ejVaC9gA)
|
||||||
|
|
||||||
|
[](https://reddit.com/r/claper)
|
||||||
|
|
||||||
|
[-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
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
255
assets/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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")],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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
10
dev.sh
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
66
lib/claper/accounts/oidc/user.ex
Normal file
66
lib/claper/accounts/oidc/user.ex
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
115
lib/claper/interactions.ex
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
70
lib/claper_web/controllers/lti/grade_controller.ex
Normal file
70
lib/claper_web/controllers/lti/grade_controller.ex
Normal 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
|
||||||
72
lib/claper_web/controllers/lti/launch_controller.ex
Normal file
72
lib/claper_web/controllers/lti/launch_controller.ex
Normal 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
|
||||||
110
lib/claper_web/controllers/lti/registration_controller.ex
Normal file
110
lib/claper_web/controllers/lti/registration_controller.ex
Normal 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
|
||||||
@@ -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.
|
||||||
|
|||||||
118
lib/claper_web/controllers/user_oidc_auth.ex
Normal file
118
lib/claper_web/controllers/user_oidc_auth.ex
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
47
lib/claper_web/live/event_live/embed_iframe_component.ex
Normal file
47
lib/claper_web/live/event_live/embed_iframe_component.ex
Normal 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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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 =~ "?"}
|
||||||
|
|||||||
@@ -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>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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") %>
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
21
lib/claper_web/templates/lti/launch/error.html.heex
Normal file
21
lib/claper_web/templates/lti/launch/error.html.heex
Normal 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>
|
||||||
8
lib/claper_web/templates/lti/launch/success.html.heex
Normal file
8
lib/claper_web/templates/lti/launch/success.html.heex
Normal 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>
|
||||||
46
lib/claper_web/templates/lti/registration/new.html.heex
Normal file
46
lib/claper_web/templates/lti/registration/new.html.heex
Normal 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>
|
||||||
30
lib/claper_web/templates/lti/registration/success.html.heex
Normal file
30
lib/claper_web/templates/lti/registration/success.html.heex
Normal 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>
|
||||||
@@ -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"
|
||||||
) %>
|
) %>
|
||||||
|
|||||||
@@ -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"
|
||||||
) %>
|
) %>
|
||||||
|
|||||||
3
lib/claper_web/views/lti/grade_view.ex
Normal file
3
lib/claper_web/views/lti/grade_view.ex
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
defmodule ClaperWeb.Lti.GradeView do
|
||||||
|
use ClaperWeb, :view
|
||||||
|
end
|
||||||
3
lib/claper_web/views/lti/launch_view.ex
Normal file
3
lib/claper_web/views/lti/launch_view.ex
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
defmodule ClaperWeb.Lti.LaunchView do
|
||||||
|
use ClaperWeb, :view
|
||||||
|
end
|
||||||
3
lib/claper_web/views/lti/registration_view.ex
Normal file
3
lib/claper_web/views/lti/registration_view.ex
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
defmodule ClaperWeb.Lti.RegistrationView do
|
||||||
|
use ClaperWeb, :view
|
||||||
|
end
|
||||||
2
lib/lti_13.ex
Normal file
2
lib/lti_13.ex
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
defmodule Lti13 do
|
||||||
|
end
|
||||||
37
lib/lti_13/deployments.ex
Normal file
37
lib/lti_13/deployments.ex
Normal 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
|
||||||
19
lib/lti_13/deployments/deployment.ex
Normal file
19
lib/lti_13/deployments/deployment.ex
Normal 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
55
lib/lti_13/jwks.ex
Normal 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
23
lib/lti_13/jwks/jwk.ex
Normal 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
|
||||||
61
lib/lti_13/jwks/utils/key_generator.ex
Normal file
61
lib/lti_13/jwks/utils/key_generator.ex
Normal 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
|
||||||
210
lib/lti_13/jwks/utils/validator.ex
Normal file
210
lib/lti_13/jwks/utils/validator.ex
Normal 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
27
lib/lti_13/nonces.ex
Normal 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
|
||||||
20
lib/lti_13/nonces/nonce.ex
Normal file
20
lib/lti_13/nonces/nonce.ex
Normal 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
|
||||||
40
lib/lti_13/registrations.ex
Normal file
40
lib/lti_13/registrations.ex
Normal 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
|
||||||
54
lib/lti_13/registrations/registration.ex
Normal file
54
lib/lti_13/registrations/registration.ex
Normal 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
60
lib/lti_13/resources.ex
Normal 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
|
||||||
31
lib/lti_13/resources/resource.ex
Normal file
31
lib/lti_13/resources/resource.ex
Normal 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
|
||||||
213
lib/lti_13/tool/launch_validation.ex
Normal file
213
lib/lti_13/tool/launch_validation.ex
Normal 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
|
||||||
7
lib/lti_13/tool/message_validators/message_validator.ex
Normal file
7
lib/lti_13/tool/message_validators/message_validator.ex
Normal 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
|
||||||
@@ -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
|
||||||
86
lib/lti_13/tool/oidc_login.ex
Normal file
86
lib/lti_13/tool/oidc_login.ex
Normal 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
|
||||||
119
lib/lti_13/tool/services/access_token.ex
Normal file
119
lib/lti_13/tool/services/access_token.ex
Normal 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
|
||||||
308
lib/lti_13/tool/services/ags.ex
Normal file
308
lib/lti_13/tool/services/ags.ex
Normal 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
|
||||||
25
lib/lti_13/tool/services/ags/line_item.ex
Normal file
25
lib/lti_13/tool/services/ags/line_item.ex
Normal 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
Reference in New Issue
Block a user