Merge branch 'release/0.7.1'
6
.formatter.exs
Normal file
@ -0,0 +1,6 @@
|
||||
[
|
||||
import_deps: [:ecto, :ecto_sql, :phoenix],
|
||||
subdirectories: ["priv/*/migrations"],
|
||||
plugins: [Phoenix.LiveView.HTMLFormatter],
|
||||
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
|
||||
]
|
||||
37
.gitignore
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
|
||||
# Where 3rd-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
|
||||
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||
/.fetch
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
beet_round_server-*.tar
|
||||
|
||||
# Ignore assets that are produced by build tools.
|
||||
/priv/static/assets/
|
||||
|
||||
# Ignore digested assets cache.
|
||||
/priv/static/cache_manifest.json
|
||||
|
||||
# In case you use Node.js/npm, you want to ignore these.
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
124
README.md
Normal file
@ -0,0 +1,124 @@
|
||||
# BeetRoundServer
|
||||
|
||||
To start your Phoenix server:
|
||||
|
||||
- Run `mix setup` to install and setup dependencies
|
||||
- Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
|
||||
|
||||
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
||||
|
||||
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
||||
|
||||
# Deployment on new uberspace asteroid
|
||||
|
||||
## Initial deployment
|
||||
|
||||
### Add subdomain
|
||||
|
||||
uberspace web domain add beetround.example.com
|
||||
|
||||
### Init database
|
||||
|
||||
Follow guide to initialize postgresql database:
|
||||
https://lab.uberspace.de/guide_postgresql/
|
||||
|
||||
#### Configure database
|
||||
|
||||
createuser beetround_admin -P
|
||||
|
||||
createdb --encoding=UTF8 --owner=beetround_admin --template=template0 beetround_server
|
||||
|
||||
## Configure Elixir/Phoenix
|
||||
|
||||
uberspace tools version use erlang 27
|
||||
|
||||
## Build & run BeetRound
|
||||
|
||||
cd ~/
|
||||
|
||||
mkdir develop
|
||||
|
||||
git clone https://git.working-copy.org/bent/BeetRoundServer.git
|
||||
|
||||
cd develop
|
||||
|
||||
export MIX_ENV=prod
|
||||
|
||||
mix deps.get
|
||||
|
||||
mix phx.gen.secret
|
||||
|
||||
export SECRET_KEY_BASE=<SECRET_KEY>
|
||||
|
||||
export DATABASE_URL=ecto://beetround_admin:<DB_PASSWORD>@localhost/beetround_server
|
||||
|
||||
mix assets.deploy #throws "'mix tailwind beet_round_server --minify' exited with 1" error
|
||||
|
||||
Workaround: copy assets from develop machine
|
||||
|
||||
mix compile
|
||||
|
||||
PHX_HOST=beetround.example.com PORT=4005 mix ecto.migrate
|
||||
|
||||
### Create webbackend
|
||||
|
||||
uberspace web backend set beetround.example.com --http --port 4005
|
||||
|
||||
#### Test backend
|
||||
|
||||
PHX_HOST=beetround.example.com PORT=4005 mix phx.server
|
||||
|
||||
#### Create mix release
|
||||
|
||||
mix release
|
||||
|
||||
### Create service
|
||||
|
||||
nvim ~/etc/services.d/beetround_server.ini
|
||||
|
||||
```
|
||||
[program:beetround_server]
|
||||
command=%(ENV_HOME)s/develop/BeetRoundServer/_build/prod/rel/beet_round_server/bin/beet_round_server
|
||||
directory=%(ENV_HOME)s/develop/BeetRoundServer
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=60
|
||||
environment =
|
||||
MAIL_RELAY="<UBERSPACE_ASTEROID>.uberspace.de",
|
||||
MAIL_ADDRESS="<MAIL_ADDRESS>",
|
||||
MAIL_PW="<MAIL_PASSWORD>",
|
||||
PHX_HOST="beetround.example.com",
|
||||
MIX_ENV=prod,
|
||||
PORT=4005,
|
||||
DATABASE_URL="ecto://beetround_admin:<DB_PASSWORD>@localhost/beetround_server",
|
||||
SECRET_KEY_BASE=<SECRET_KEY>
|
||||
```
|
||||
|
||||
supervisorctl reread
|
||||
|
||||
supervisorctl update
|
||||
|
||||
supervisorctl status
|
||||
|
||||
## Updates (TODO old content. needs to be adjusted/checked)
|
||||
|
||||
Steps on develop environment:
|
||||
|
||||
- create new release version (with git flow)
|
||||
- push main branch to repo
|
||||
|
||||
Steps on server:
|
||||
|
||||
- cd develop/SplitPot/
|
||||
- pull main branch
|
||||
- mix deps.get --only prod
|
||||
- MIX_ENV=prod mix assets.deploy
|
||||
- export BUILD_DATE="DD.MM.YYYY"
|
||||
- MIX_ENV=prod mix release
|
||||
- supervisorctl stop splitpot_server
|
||||
- DB migrations
|
||||
- export DATABASE_URL=...
|
||||
- export SECRET_BASE_KEY=...
|
||||
- MIX_ENV=prod mix ecto.migrate
|
||||
|
||||
- supervisorctl start splitpot_server
|
||||
105
assets/css/app.css
Normal file
@ -0,0 +1,105 @@
|
||||
/* See the Tailwind configuration guide for advanced usage
|
||||
https://tailwindcss.com/docs/configuration */
|
||||
|
||||
@import "tailwindcss" source(none);
|
||||
@source "../css";
|
||||
@source "../js";
|
||||
@source "../../lib/beet_round_server_web";
|
||||
|
||||
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
|
||||
The heroicons installation itself is managed by your mix.exs */
|
||||
@plugin "../vendor/heroicons";
|
||||
|
||||
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
|
||||
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
|
||||
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
|
||||
@plugin "../vendor/daisyui" {
|
||||
themes: false;
|
||||
}
|
||||
|
||||
/* daisyUI theme plugin. You can update this file by fetching the latest version with:
|
||||
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
|
||||
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
|
||||
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
|
||||
@plugin "../vendor/daisyui-theme" {
|
||||
name: "dark";
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(30.33% 0.016 252.42);
|
||||
--color-base-200: oklch(25.26% 0.014 253.1);
|
||||
--color-base-300: oklch(20.15% 0.012 254.09);
|
||||
--color-base-content: oklch(97.807% 0.029 256.847);
|
||||
--color-primary: oklch(58% 0.233 277.117);
|
||||
--color-primary-content: oklch(96% 0.018 272.314);
|
||||
--color-secondary: oklch(58% 0.233 277.117);
|
||||
--color-secondary-content: oklch(96% 0.018 272.314);
|
||||
--color-accent: oklch(60% 0.25 292.717);
|
||||
--color-accent-content: oklch(96% 0.016 293.756);
|
||||
--color-neutral: oklch(37% 0.044 257.287);
|
||||
--color-neutral-content: oklch(98% 0.003 247.858);
|
||||
--color-info: oklch(58% 0.158 241.966);
|
||||
--color-info-content: oklch(97% 0.013 236.62);
|
||||
--color-success: oklch(60% 0.118 184.704);
|
||||
--color-success-content: oklch(98% 0.014 180.72);
|
||||
--color-warning: oklch(66% 0.179 58.318);
|
||||
--color-warning-content: oklch(98% 0.022 95.277);
|
||||
--color-error: oklch(58% 0.253 17.585);
|
||||
--color-error-content: oklch(96% 0.015 12.422);
|
||||
--radius-selector: 0.25rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.21875rem;
|
||||
--size-field: 0.21875rem;
|
||||
--border: 1.5px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@plugin "../vendor/daisyui-theme" {
|
||||
name: "light";
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: "light";
|
||||
--color-base-100: oklch(98% 0 0);
|
||||
--color-base-200: oklch(96% 0.001 286.375);
|
||||
--color-base-300: oklch(92% 0.004 286.32);
|
||||
--color-base-content: oklch(21% 0.006 285.885);
|
||||
--color-primary: oklch(70% 0.213 47.604);
|
||||
--color-primary-content: oklch(98% 0.016 73.684);
|
||||
--color-secondary: oklch(55% 0.027 264.364);
|
||||
--color-secondary-content: oklch(98% 0.002 247.839);
|
||||
--color-accent: oklch(0% 0 0);
|
||||
--color-accent-content: oklch(100% 0 0);
|
||||
--color-neutral: oklch(44% 0.017 285.786);
|
||||
--color-neutral-content: oklch(98% 0 0);
|
||||
--color-info: oklch(62% 0.214 259.815);
|
||||
--color-info-content: oklch(97% 0.014 254.604);
|
||||
--color-success: oklch(70% 0.14 182.503);
|
||||
--color-success-content: oklch(98% 0.014 180.72);
|
||||
--color-warning: oklch(66% 0.179 58.318);
|
||||
--color-warning-content: oklch(98% 0.022 95.277);
|
||||
--color-error: oklch(58% 0.253 17.585);
|
||||
--color-error-content: oklch(96% 0.015 12.422);
|
||||
--radius-selector: 0.25rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.21875rem;
|
||||
--size-field: 0.21875rem;
|
||||
--border: 1.5px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Add variants based on LiveView classes */
|
||||
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
|
||||
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
|
||||
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
|
||||
|
||||
/* Use the data attribute for dark mode */
|
||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
|
||||
/* Make LiveView wrapper divs transparent for layout */
|
||||
[data-phx-session], [data-phx-teleported-src] { display: contents }
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
83
assets/js/app.js
Normal file
@ -0,0 +1,83 @@
|
||||
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
|
||||
// to get started and then uncomment the line below.
|
||||
// import "./user_socket.js"
|
||||
|
||||
// You can include dependencies in two ways.
|
||||
//
|
||||
// The simplest option is to put them in assets/vendor and
|
||||
// import them using relative paths:
|
||||
//
|
||||
// import "../vendor/some-package.js"
|
||||
//
|
||||
// Alternatively, you can `npm install some-package --prefix assets` and import
|
||||
// them using a path starting with the package name:
|
||||
//
|
||||
// import "some-package"
|
||||
//
|
||||
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
|
||||
// To load it, simply add a second `<link>` to your `root.html.heex` file.
|
||||
|
||||
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
||||
import "phoenix_html"
|
||||
// Establish Phoenix Socket and LiveView configuration.
|
||||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import {hooks as colocatedHooks} from "phoenix-colocated/beet_round_server"
|
||||
import topbar from "../vendor/topbar"
|
||||
|
||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken},
|
||||
hooks: {...colocatedHooks},
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
liveSocket.connect()
|
||||
|
||||
// expose liveSocket on window for web console debug logs and latency simulation:
|
||||
// >> liveSocket.enableDebug()
|
||||
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket
|
||||
|
||||
// The lines below enable quality of life phoenix_live_reload
|
||||
// development features:
|
||||
//
|
||||
// 1. stream server logs to the browser console
|
||||
// 2. click on elements to jump to their definitions in your code editor
|
||||
//
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
|
||||
// Enable server log streaming to client.
|
||||
// Disable with reloader.disableServerLogs()
|
||||
reloader.enableServerLogs()
|
||||
|
||||
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
|
||||
//
|
||||
// * click with "c" key pressed to open at caller location
|
||||
// * click with "d" key pressed to open at function component definition location
|
||||
let keyDown
|
||||
window.addEventListener("keydown", e => keyDown = e.key)
|
||||
window.addEventListener("keyup", e => keyDown = null)
|
||||
window.addEventListener("click", e => {
|
||||
if(keyDown === "c"){
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
reloader.openEditorAtCaller(e.target)
|
||||
} else if(keyDown === "d"){
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
reloader.openEditorAtDef(e.target)
|
||||
}
|
||||
}, true)
|
||||
|
||||
window.liveReloader = reloader
|
||||
})
|
||||
}
|
||||
|
||||
32
assets/tsconfig.json
Normal file
@ -0,0 +1,32 @@
|
||||
// This file is needed on most editors to enable the intelligent autocompletion
|
||||
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
|
||||
//
|
||||
// Note: This file assumes a basic esbuild setup without node_modules.
|
||||
// We include a generic paths alias to deps to mimic how esbuild resolves
|
||||
// the Phoenix and LiveView JavaScript assets.
|
||||
// If you have a package.json in your project, you should remove the
|
||||
// paths configuration and instead add the phoenix dependencies to the
|
||||
// dependencies section of your package.json:
|
||||
//
|
||||
// {
|
||||
// ...
|
||||
// "dependencies": {
|
||||
// ...,
|
||||
// "phoenix": "../deps/phoenix",
|
||||
// "phoenix_html": "../deps/phoenix_html",
|
||||
// "phoenix_live_view": "../deps/phoenix_live_view"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Feel free to adjust this configuration however you need.
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": ["../deps/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["js/**/*"]
|
||||
}
|
||||
124
assets/vendor/daisyui-theme.js
vendored
Normal file
1031
assets/vendor/daisyui.js
vendored
Normal file
43
assets/vendor/heroicons.js
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
const plugin = require("tailwindcss/plugin")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
module.exports = plugin(function({matchComponents, theme}) {
|
||||
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
|
||||
let values = {}
|
||||
let icons = [
|
||||
["", "/24/outline"],
|
||||
["-solid", "/24/solid"],
|
||||
["-mini", "/20/solid"],
|
||||
["-micro", "/16/solid"]
|
||||
]
|
||||
icons.forEach(([suffix, dir]) => {
|
||||
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
|
||||
let name = path.basename(file, ".svg") + suffix
|
||||
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
|
||||
})
|
||||
})
|
||||
matchComponents({
|
||||
"hero": ({name, fullPath}) => {
|
||||
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
|
||||
content = encodeURIComponent(content)
|
||||
let size = theme("spacing.6")
|
||||
if (name.endsWith("-mini")) {
|
||||
size = theme("spacing.5")
|
||||
} else if (name.endsWith("-micro")) {
|
||||
size = theme("spacing.4")
|
||||
}
|
||||
return {
|
||||
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
|
||||
"-webkit-mask": `var(--hero-${name})`,
|
||||
"mask": `var(--hero-${name})`,
|
||||
"mask-repeat": "no-repeat",
|
||||
"background-color": "currentColor",
|
||||
"vertical-align": "middle",
|
||||
"display": "inline-block",
|
||||
"width": size,
|
||||
"height": size
|
||||
}
|
||||
}
|
||||
}, {values})
|
||||
})
|
||||
138
assets/vendor/topbar.js
vendored
Normal file
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @license MIT
|
||||
* topbar 3.0.0
|
||||
* http://buunguyen.github.io/topbar
|
||||
* Copyright (c) 2024 Buu Nguyen
|
||||
*/
|
||||
(function (window, document) {
|
||||
"use strict";
|
||||
|
||||
var canvas,
|
||||
currentProgress,
|
||||
showing,
|
||||
progressTimerId = null,
|
||||
fadeTimerId = null,
|
||||
delayTimerId = null,
|
||||
addEvent = function (elem, type, handler) {
|
||||
if (elem.addEventListener) elem.addEventListener(type, handler, false);
|
||||
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
|
||||
else elem["on" + type] = handler;
|
||||
},
|
||||
options = {
|
||||
autoRun: true,
|
||||
barThickness: 3,
|
||||
barColors: {
|
||||
0: "rgba(26, 188, 156, .9)",
|
||||
".25": "rgba(52, 152, 219, .9)",
|
||||
".50": "rgba(241, 196, 15, .9)",
|
||||
".75": "rgba(230, 126, 34, .9)",
|
||||
"1.0": "rgba(211, 84, 0, .9)",
|
||||
},
|
||||
shadowBlur: 10,
|
||||
shadowColor: "rgba(0, 0, 0, .6)",
|
||||
className: null,
|
||||
},
|
||||
repaint = function () {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = options.barThickness * 5; // need space for shadow
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.shadowBlur = options.shadowBlur;
|
||||
ctx.shadowColor = options.shadowColor;
|
||||
|
||||
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||
for (var stop in options.barColors)
|
||||
lineGradient.addColorStop(stop, options.barColors[stop]);
|
||||
ctx.lineWidth = options.barThickness;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, options.barThickness / 2);
|
||||
ctx.lineTo(
|
||||
Math.ceil(currentProgress * canvas.width),
|
||||
options.barThickness / 2
|
||||
);
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.stroke();
|
||||
},
|
||||
createCanvas = function () {
|
||||
canvas = document.createElement("canvas");
|
||||
var style = canvas.style;
|
||||
style.position = "fixed";
|
||||
style.top = style.left = style.right = style.margin = style.padding = 0;
|
||||
style.zIndex = 100001;
|
||||
style.display = "none";
|
||||
if (options.className) canvas.classList.add(options.className);
|
||||
addEvent(window, "resize", repaint);
|
||||
},
|
||||
topbar = {
|
||||
config: function (opts) {
|
||||
for (var key in opts)
|
||||
if (options.hasOwnProperty(key)) options[key] = opts[key];
|
||||
},
|
||||
show: function (delay) {
|
||||
if (showing) return;
|
||||
if (delay) {
|
||||
if (delayTimerId) return;
|
||||
delayTimerId = setTimeout(() => topbar.show(), delay);
|
||||
} else {
|
||||
showing = true;
|
||||
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
|
||||
if (!canvas) createCanvas();
|
||||
if (!canvas.parentElement) document.body.appendChild(canvas);
|
||||
canvas.style.opacity = 1;
|
||||
canvas.style.display = "block";
|
||||
topbar.progress(0);
|
||||
if (options.autoRun) {
|
||||
(function loop() {
|
||||
progressTimerId = window.requestAnimationFrame(loop);
|
||||
topbar.progress(
|
||||
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
|
||||
);
|
||||
})();
|
||||
}
|
||||
}
|
||||
},
|
||||
progress: function (to) {
|
||||
if (typeof to === "undefined") return currentProgress;
|
||||
if (typeof to === "string") {
|
||||
to =
|
||||
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
|
||||
? currentProgress
|
||||
: 0) + parseFloat(to);
|
||||
}
|
||||
currentProgress = to > 1 ? 1 : to;
|
||||
repaint();
|
||||
return currentProgress;
|
||||
},
|
||||
hide: function () {
|
||||
clearTimeout(delayTimerId);
|
||||
delayTimerId = null;
|
||||
if (!showing) return;
|
||||
showing = false;
|
||||
if (progressTimerId != null) {
|
||||
window.cancelAnimationFrame(progressTimerId);
|
||||
progressTimerId = null;
|
||||
}
|
||||
(function loop() {
|
||||
if (topbar.progress("+.1") >= 1) {
|
||||
canvas.style.opacity -= 0.05;
|
||||
if (canvas.style.opacity <= 0.05) {
|
||||
canvas.style.display = "none";
|
||||
fadeTimerId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
fadeTimerId = window.requestAnimationFrame(loop);
|
||||
})();
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module === "object" && typeof module.exports === "object") {
|
||||
module.exports = topbar;
|
||||
} else if (typeof define === "function" && define.amd) {
|
||||
define(function () {
|
||||
return topbar;
|
||||
});
|
||||
} else {
|
||||
this.topbar = topbar;
|
||||
}
|
||||
}.call(this, window, document));
|
||||
92
config/config.exs
Normal file
@ -0,0 +1,92 @@
|
||||
# This file is responsible for configuring your application
|
||||
# and its dependencies with the aid of the Config module.
|
||||
#
|
||||
# This configuration file is loaded before any dependency and
|
||||
# is restricted to this project.
|
||||
|
||||
# General application configuration
|
||||
import Config
|
||||
|
||||
config :beet_round_server, :scopes,
|
||||
admin: [
|
||||
default: false,
|
||||
module: BeetRoundServer.Admins.Scope,
|
||||
assign_key: :current_scope,
|
||||
access_path: [:admin, :id],
|
||||
schema_key: :admin_id,
|
||||
schema_type: :binary_id,
|
||||
schema_table: :admins,
|
||||
test_data_fixture: BeetRoundServer.AdminsFixtures,
|
||||
test_setup_helper: :register_and_log_in_admin
|
||||
]
|
||||
|
||||
config :beet_round_server, :scopes,
|
||||
user: [
|
||||
default: true,
|
||||
module: BeetRoundServer.Accounts.Scope,
|
||||
assign_key: :current_scope,
|
||||
access_path: [:user, :id],
|
||||
schema_key: :user_id,
|
||||
schema_type: :binary_id,
|
||||
schema_table: :users,
|
||||
test_data_fixture: BeetRoundServer.AccountsFixtures,
|
||||
test_setup_helper: :register_and_log_in_user
|
||||
]
|
||||
|
||||
config :beet_round_server,
|
||||
ecto_repos: [BeetRoundServer.Repo],
|
||||
generators: [timestamp_type: :utc_datetime, binary_id: true]
|
||||
|
||||
# Configures the endpoint
|
||||
config :beet_round_server, BeetRoundServerWeb.Endpoint,
|
||||
url: [host: "https://beetround.example.com"],
|
||||
adapter: Bandit.PhoenixAdapter,
|
||||
render_errors: [
|
||||
formats: [html: BeetRoundServerWeb.ErrorHTML, json: BeetRoundServerWeb.ErrorJSON],
|
||||
layout: false
|
||||
],
|
||||
pubsub_server: BeetRoundServer.PubSub,
|
||||
live_view: [signing_salt: "4HDgM4VC"],
|
||||
server: true
|
||||
|
||||
# Configures the mailer
|
||||
#
|
||||
# By default it uses the "Local" adapter which stores the emails
|
||||
# locally. You can see the emails in your browser, at "/dev/mailbox".
|
||||
#
|
||||
# For production it's recommended to configure a different adapter
|
||||
# at the `config/runtime.exs`.
|
||||
config :beet_round_server, BeetRoundServer.Mailer, adapter: Swoosh.Adapters.Local
|
||||
|
||||
# Configure esbuild (the version is required)
|
||||
config :esbuild,
|
||||
version: "0.25.4",
|
||||
beet_round_server: [
|
||||
args:
|
||||
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
|
||||
]
|
||||
|
||||
# Configure tailwind (the version is required)
|
||||
config :tailwind,
|
||||
version: "4.1.7",
|
||||
beet_round_server: [
|
||||
args: ~w(
|
||||
--input=assets/css/app.css
|
||||
--output=priv/static/assets/css/app.css
|
||||
),
|
||||
cd: Path.expand("..", __DIR__)
|
||||
]
|
||||
|
||||
# Configures Elixir's Logger
|
||||
config :logger, :default_formatter,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
metadata: [:request_id]
|
||||
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
config :phoenix, :json_library, Jason
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{config_env()}.exs"
|
||||
88
config/dev.exs
Normal file
@ -0,0 +1,88 @@
|
||||
import Config
|
||||
|
||||
# Configure your database
|
||||
config :beet_round_server, BeetRoundServer.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
hostname: "localhost",
|
||||
database: "beet_round_server_dev",
|
||||
stacktrace: true,
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
pool_size: 10
|
||||
|
||||
# For development, we disable any cache and enable
|
||||
# debugging and code reloading.
|
||||
#
|
||||
# The watchers configuration can be used to run external
|
||||
# watchers to your application. For example, we can use it
|
||||
# to bundle .js and .css sources.
|
||||
config :beet_round_server, BeetRoundServerWeb.Endpoint,
|
||||
# Binding to loopback ipv4 address prevents access from other machines.
|
||||
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
|
||||
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")],
|
||||
check_origin: false,
|
||||
code_reloader: true,
|
||||
debug_errors: false,
|
||||
secret_key_base: "ha7k0JD3LvWrZgupXaVbXq6SQhvTwc6R/HyqCJIpANEtcSni88QELgaYgvr5I49M",
|
||||
watchers: [
|
||||
esbuild: {Esbuild, :install_and_run, [:beet_round_server, ~w(--sourcemap=inline --watch)]},
|
||||
tailwind: {Tailwind, :install_and_run, [:beet_round_server, ~w(--watch)]}
|
||||
]
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# In order to use HTTPS in development, a self-signed
|
||||
# certificate can be generated by running the following
|
||||
# Mix task:
|
||||
#
|
||||
# mix phx.gen.cert
|
||||
#
|
||||
# Run `mix help phx.gen.cert` for more information.
|
||||
#
|
||||
# The `http:` config above can be replaced with:
|
||||
#
|
||||
# https: [
|
||||
# port: 4001,
|
||||
# cipher_suite: :strong,
|
||||
# keyfile: "priv/cert/selfsigned_key.pem",
|
||||
# certfile: "priv/cert/selfsigned.pem"
|
||||
# ],
|
||||
#
|
||||
# If desired, both `http:` and `https:` keys can be
|
||||
# configured to run both http and https servers on
|
||||
# different ports.
|
||||
|
||||
# Watch static and templates for browser reloading.
|
||||
config :beet_round_server, BeetRoundServerWeb.Endpoint,
|
||||
live_reload: [
|
||||
web_console_logger: true,
|
||||
patterns: [
|
||||
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
|
||||
~r"priv/gettext/.*(po)$",
|
||||
~r"lib/beet_round_server_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$"
|
||||
]
|
||||
]
|
||||
|
||||
# Enable dev routes for dashboard and mailbox
|
||||
config :beet_round_server, dev_routes: true
|
||||
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :default_formatter, format: "[$level] $message\n"
|
||||
|
||||
# Set a higher stacktrace during development. Avoid configuring such
|
||||
# in production as building large stacktraces may be expensive.
|
||||
config :phoenix, :stacktrace_depth, 20
|
||||
|
||||
# Initialize plugs at runtime for faster development compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
config :phoenix_live_view,
|
||||
# Include debug annotations and locations in rendered markup.
|
||||
# Changing this configuration will require mix clean and a full recompile.
|
||||
debug_heex_annotations: true,
|
||||
debug_attributes: true,
|
||||
# Enable helpful, but potentially expensive runtime checks
|
||||
enable_expensive_runtime_checks: true
|
||||
|
||||
# Disable swoosh api client as it is only required for production adapters.
|
||||
config :swoosh, :api_client, false
|
||||
23
config/prod.exs
Normal file
@ -0,0 +1,23 @@
|
||||
import Config
|
||||
|
||||
# Note we also include the path to a cache manifest
|
||||
# containing the digested version of static files. This
|
||||
# manifest is generated by the `mix assets.deploy` task,
|
||||
# which you should run after static files are built and
|
||||
# before starting your production server.
|
||||
config :beet_round_server, BeetRoundServerWeb.Endpoint,
|
||||
cache_static_manifest: "priv/static/cache_manifest.json",
|
||||
url: [host: "https://beetround.example.com"],
|
||||
check_origin: ["https://beetround.example.com"]
|
||||
|
||||
# Configures Swoosh API Client
|
||||
config :swoosh, api_client: Swoosh.ApiClient.Req
|
||||
|
||||
# Disable Swoosh Local Memory Storage
|
||||
config :swoosh, local: false
|
||||
|
||||
# Do not print debug messages in production
|
||||
config :logger, level: :info
|
||||
|
||||
# Runtime production configuration, including reading
|
||||
# of environment variables, is done on config/runtime.exs.
|
||||
135
config/runtime.exs
Normal file
@ -0,0 +1,135 @@
|
||||
import Config
|
||||
|
||||
# config/runtime.exs is executed for all environments, including
|
||||
# during releases. It is executed after compilation and before the
|
||||
# system starts, so it is typically used to load production configuration
|
||||
# and secrets from environment variables or elsewhere. Do not define
|
||||
# any compile-time configuration in here, as it won't be applied.
|
||||
# The block below contains prod specific runtime configuration.
|
||||
|
||||
# ## Using releases
|
||||
#
|
||||
# If you use `mix release`, you need to explicitly enable the server
|
||||
# by passing the PHX_SERVER=true when you start it:
|
||||
#
|
||||
# PHX_SERVER=true bin/beet_round_server start
|
||||
#
|
||||
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
|
||||
# script that automatically sets the env var above.
|
||||
if System.get_env("PHX_SERVER") do
|
||||
config :beet_round_server, BeetRoundServerWeb.Endpoint, server: true
|
||||
end
|
||||
|
||||
if config_env() == :prod do
|
||||
database_url =
|
||||
System.get_env("DATABASE_URL") ||
|
||||
raise """
|
||||
environment variable DATABASE_URL is missing.
|
||||
For example: ecto://USER:PASS@HOST/DATABASE
|
||||
"""
|
||||
|
||||
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
|
||||
|
||||
config :beet_round_server, BeetRoundServer.Repo,
|
||||
# ssl: true,
|
||||
url: database_url,
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
|
||||
# For machines with several cores, consider starting multiple pools of `pool_size`
|
||||
# pool_count: 4,
|
||||
socket_options: maybe_ipv6
|
||||
|
||||
# The secret key base is used to sign/encrypt cookies and other secrets.
|
||||
# A default value is used in config/dev.exs and config/test.exs but you
|
||||
# want to use a different value for prod and you most likely don't want
|
||||
# to check this value into version control, so we use an environment
|
||||
# variable instead.
|
||||
secret_key_base =
|
||||
System.get_env("SECRET_KEY_BASE") ||
|
||||
raise """
|
||||
environment variable SECRET_KEY_BASE is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
"""
|
||||
|
||||
host = System.get_env("PHX_HOST") || "example.com"
|
||||
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||
|
||||
config :beet_round_server, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||
|
||||
config :beet_round_server, BeetRoundServerWeb.Endpoint,
|
||||
url: [host: host, port: 443, scheme: "https"],
|
||||
http: [
|
||||
# Enable IPv6 and bind on all interfaces.
|
||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
|
||||
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
||||
port: port
|
||||
],
|
||||
secret_key_base: secret_key_base
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# To get SSL working, you will need to add the `https` key
|
||||
# to your endpoint configuration:
|
||||
#
|
||||
# config :beet_round_server, BeetRoundServerWeb.Endpoint,
|
||||
# https: [
|
||||
# ...,
|
||||
# port: 443,
|
||||
# cipher_suite: :strong,
|
||||
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
|
||||
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
|
||||
# ]
|
||||
#
|
||||
# The `cipher_suite` is set to `:strong` to support only the
|
||||
# latest and more secure SSL ciphers. This means old browsers
|
||||
# and clients may not be supported. You can set it to
|
||||
# `:compatible` for wider support.
|
||||
#
|
||||
# `:keyfile` and `:certfile` expect an absolute path to the key
|
||||
# and cert in disk or a relative path inside priv, for example
|
||||
# "priv/ssl/server.key". For all supported SSL configuration
|
||||
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
|
||||
#
|
||||
# We also recommend setting `force_ssl` in your config/prod.exs,
|
||||
# ensuring no data is ever sent via http, always redirecting to https:
|
||||
#
|
||||
# config :beet_round_server, BeetRoundServerWeb.Endpoint,
|
||||
# force_ssl: [hsts: true]
|
||||
#
|
||||
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||
|
||||
# ## Configuring the mailer
|
||||
#
|
||||
# In production you need to configure the mailer to use a different adapter.
|
||||
# Here is an example configuration for Mailgun:
|
||||
#
|
||||
# config :beet_round_server, BeetRoundServer.Mailer,
|
||||
# adapter: Swoosh.Adapters.Mailgun,
|
||||
# api_key: System.get_env("MAILGUN_API_KEY"),
|
||||
# domain: System.get_env("MAILGUN_DOMAIN")
|
||||
#
|
||||
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
|
||||
# and Finch out-of-the-box. This configuration is typically done at
|
||||
# compile-time in your config/prod.exs:
|
||||
#
|
||||
# config :swoosh, :api_client, Swoosh.ApiClient.Req
|
||||
#
|
||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
||||
mail_relay = System.get_env("MAIL_RELAY") || "example.com"
|
||||
mail_address = System.get_env("MAIL_ADDRESS") || "info@example.com"
|
||||
mail_pw = System.get_env("MAIL_PW") || ""
|
||||
|
||||
config :beet_round_server, BeetRoundServer.Mailer,
|
||||
adapter: Swoosh.Adapters.SMTP,
|
||||
relay: mail_relay,
|
||||
username: mail_address,
|
||||
password: mail_pw,
|
||||
ssl: false,
|
||||
ssl_opts: [verify: :verify_none],
|
||||
tls_options: [verify: :verify_none],
|
||||
tls: :always,
|
||||
auth: :always,
|
||||
port: 587,
|
||||
retries: 2
|
||||
end
|
||||
40
config/test.exs
Normal file
@ -0,0 +1,40 @@
|
||||
import Config
|
||||
|
||||
# Only in tests, remove the complexity from the password hashing algorithm
|
||||
config :bcrypt_elixir, :log_rounds, 1
|
||||
|
||||
# Configure your database
|
||||
#
|
||||
# The MIX_TEST_PARTITION environment variable can be used
|
||||
# to provide built-in test partitioning in CI environment.
|
||||
# Run `mix help test` for more information.
|
||||
config :beet_round_server, BeetRoundServer.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
hostname: "localhost",
|
||||
database: "beet_round_server_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
pool_size: System.schedulers_online() * 2
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
# you can enable the server option below.
|
||||
config :beet_round_server, BeetRoundServerWeb.Endpoint,
|
||||
http: [ip: {127, 0, 0, 1}, port: 4002],
|
||||
secret_key_base: "49oa8Q1/HBoaBLPhXNjPC2mDiVuXu/RgUbWdG6nQOGTfsjHSLlNFCloAXjGbMuDU",
|
||||
server: false
|
||||
|
||||
# In test we don't send emails
|
||||
config :beet_round_server, BeetRoundServer.Mailer, adapter: Swoosh.Adapters.Test
|
||||
|
||||
# Disable swoosh api client as it is only required for production adapters
|
||||
config :swoosh, :api_client, false
|
||||
|
||||
# Print only warnings and errors during test
|
||||
config :logger, level: :warning
|
||||
|
||||
# Initialize plugs at runtime for faster test compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
# Enable helpful, but potentially expensive runtime checks
|
||||
config :phoenix_live_view,
|
||||
enable_expensive_runtime_checks: true
|
||||
9
lib/beet_round_server.ex
Normal file
@ -0,0 +1,9 @@
|
||||
defmodule BeetRoundServer do
|
||||
@moduledoc """
|
||||
BeetRoundServer keeps the contexts that define your domain
|
||||
and business logic.
|
||||
|
||||
Contexts are also responsible for managing your data, regardless
|
||||
if it comes from the database, an external API or others.
|
||||
"""
|
||||
end
|
||||
329
lib/beet_round_server/accounts.ex
Normal file
@ -0,0 +1,329 @@
|
||||
defmodule BeetRoundServer.Accounts do
|
||||
@moduledoc """
|
||||
The Accounts context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias BeetRoundServer.Repo
|
||||
|
||||
alias BeetRoundServer.Accounts.{User, UserToken, UserNotifier}
|
||||
|
||||
## Database getters
|
||||
|
||||
@doc """
|
||||
Returns the list of users.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_users()
|
||||
[%User{}, ...]
|
||||
|
||||
"""
|
||||
def list_users do
|
||||
Repo.all(User)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a user by email.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email("foo@example.com")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email("unknown@example.com")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_email(email) when is_binary(email) do
|
||||
Repo.get_by(User, email: email)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a user by email and password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_email_and_password(email, password)
|
||||
when is_binary(email) and is_binary(password) do
|
||||
user = Repo.get_by(User, email: email)
|
||||
if User.valid_password?(user, password), do: user
|
||||
end
|
||||
|
||||
def get_user_by_email_token(token) do
|
||||
{:ok, query} =
|
||||
UserToken.verify_email_token_query(token, "session")
|
||||
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single user.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the User does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user!(123)
|
||||
%User{}
|
||||
|
||||
iex> get_user!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_user!(id), do: Repo.get!(User, id)
|
||||
|
||||
## User registration
|
||||
|
||||
@doc """
|
||||
Registers a user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> register_user(%{field: value})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> register_user(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def register_user(attrs) do
|
||||
%User{}
|
||||
|> User.email_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
## Settings
|
||||
|
||||
@doc """
|
||||
Checks whether the user is in sudo mode.
|
||||
|
||||
The user is in sudo mode when the last authentication was done no further
|
||||
than 20 minutes ago. The limit can be given as second argument in minutes.
|
||||
"""
|
||||
def sudo_mode?(user, minutes \\ -20)
|
||||
|
||||
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
|
||||
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
|
||||
end
|
||||
|
||||
def sudo_mode?(_user, _minutes), do: false
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user email.
|
||||
|
||||
See `BeetRoundServer.Accounts.User.email_changeset/3` for a list of supported options.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_email(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_email(user, attrs \\ %{}, opts \\ []) do
|
||||
User.email_changeset(user, attrs, opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user email using the given token.
|
||||
|
||||
If the token matches, the user email is updated and the token is deleted.
|
||||
"""
|
||||
def update_user_email(user, token) do
|
||||
context = "change:#{user.email}"
|
||||
|
||||
Repo.transact(fn ->
|
||||
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
|
||||
%UserToken{sent_to: email} <- Repo.one(query),
|
||||
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
|
||||
{_count, _result} <-
|
||||
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
|
||||
{:ok, user}
|
||||
else
|
||||
_ -> {:error, :transaction_aborted}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user password.
|
||||
|
||||
See `BeetRoundServer.Accounts.User.password_changeset/3` for a list of supported options.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_password(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_password(user, attrs \\ %{}, opts \\ []) do
|
||||
User.password_changeset(user, attrs, opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user password.
|
||||
|
||||
Returns a tuple with the updated user, as well as a list of expired tokens.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_user_password(user, %{password: ...})
|
||||
{:ok, {%User{}, [...]}}
|
||||
|
||||
iex> update_user_password(user, %{password: "too short"})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_user_password(user, attrs) do
|
||||
user
|
||||
|> User.password_changeset(attrs)
|
||||
|> update_user_and_delete_all_tokens()
|
||||
end
|
||||
|
||||
def create_email_token(%User{} = user) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "session")
|
||||
Repo.insert!(user_token)
|
||||
|
||||
encoded_token
|
||||
end
|
||||
|
||||
## Session
|
||||
|
||||
@doc """
|
||||
Generates a session token.
|
||||
"""
|
||||
def generate_user_session_token(user) do
|
||||
{token, user_token} = UserToken.build_session_token(user)
|
||||
Repo.insert!(user_token)
|
||||
token
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user with the given signed token.
|
||||
|
||||
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
|
||||
"""
|
||||
def get_user_by_session_token(token) do
|
||||
{:ok, query} = UserToken.verify_session_token_query(token)
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user with the given magic link token.
|
||||
"""
|
||||
def get_user_by_magic_link_token(token) do
|
||||
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
|
||||
{user, _token} <- Repo.one(query) do
|
||||
user
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user in by magic link.
|
||||
|
||||
There are three cases to consider:
|
||||
|
||||
1. The user has already confirmed their email. They are logged in
|
||||
and the magic link is expired.
|
||||
|
||||
2. The user has not confirmed their email and no password is set.
|
||||
In this case, the user gets confirmed, logged in, and all tokens -
|
||||
including session ones - are expired. In theory, no other tokens
|
||||
exist but we delete all of them for best security practices.
|
||||
|
||||
3. The user has not confirmed their email but a password is set.
|
||||
This cannot happen in the default implementation but may be the
|
||||
source of security pitfalls. See the "Mixing magic link and password registration" section of
|
||||
`mix help phx.gen.auth`.
|
||||
"""
|
||||
def login_user_by_magic_link(token) do
|
||||
{:ok, query} = UserToken.verify_magic_link_token_query(token)
|
||||
|
||||
case Repo.one(query) do
|
||||
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
|
||||
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
|
||||
raise """
|
||||
magic link log in is not allowed for unconfirmed users with a password set!
|
||||
|
||||
This cannot happen with the default implementation, which indicates that you
|
||||
might have adapted the code to a different use case. Please make sure to read the
|
||||
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
|
||||
"""
|
||||
|
||||
{%User{confirmed_at: nil} = user, _token} ->
|
||||
user
|
||||
|> User.confirm_changeset()
|
||||
|> update_user_and_delete_all_tokens()
|
||||
|
||||
{user, token} ->
|
||||
Repo.delete!(token)
|
||||
{:ok, {user, []}}
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Delivers the update email instructions to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
|
||||
when is_function(update_email_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
|
||||
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delivers the magic link login instructions to the given user.
|
||||
"""
|
||||
def deliver_login_instructions(%User{} = user, magic_link_url_fun)
|
||||
when is_function(magic_link_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes the signed token with the given context.
|
||||
"""
|
||||
def delete_user_session_token(token) do
|
||||
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
|
||||
:ok
|
||||
end
|
||||
|
||||
def delete_user(id) do
|
||||
user = get_user!(id)
|
||||
Repo.delete(user)
|
||||
end
|
||||
|
||||
## Token helper
|
||||
|
||||
defp update_user_and_delete_all_tokens(changeset) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, user} <- Repo.update(changeset) do
|
||||
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
|
||||
|
||||
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
|
||||
|
||||
{:ok, {user, tokens_to_expire}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
33
lib/beet_round_server/accounts/scope.ex
Normal file
@ -0,0 +1,33 @@
|
||||
defmodule BeetRoundServer.Accounts.Scope do
|
||||
@moduledoc """
|
||||
Defines the scope of the caller to be used throughout the app.
|
||||
|
||||
The `BeetRoundServer.Accounts.Scope` allows public interfaces to receive
|
||||
information about the caller, such as if the call is initiated from an
|
||||
end-user, and if so, which user. Additionally, such a scope can carry fields
|
||||
such as "super user" or other privileges for use as authorization, or to
|
||||
ensure specific code paths can only be access for a given scope.
|
||||
|
||||
It is useful for logging as well as for scoping pubsub subscriptions and
|
||||
broadcasts when a caller subscribes to an interface or performs a particular
|
||||
action.
|
||||
|
||||
Feel free to extend the fields on this struct to fit the needs of
|
||||
growing application requirements.
|
||||
"""
|
||||
|
||||
alias BeetRoundServer.Accounts.User
|
||||
|
||||
defstruct user: nil
|
||||
|
||||
@doc """
|
||||
Creates a scope for the given user.
|
||||
|
||||
Returns nil if no user is given.
|
||||
"""
|
||||
def for_user(%User{} = user) do
|
||||
%__MODULE__{user: user}
|
||||
end
|
||||
|
||||
def for_user(nil), do: nil
|
||||
end
|
||||
134
lib/beet_round_server/accounts/user.ex
Normal file
@ -0,0 +1,134 @@
|
||||
defmodule BeetRoundServer.Accounts.User do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "users" do
|
||||
field :email, :string
|
||||
field :password, :string, virtual: true, redact: true
|
||||
field :hashed_password, :string, redact: true
|
||||
field :confirmed_at, :utc_datetime
|
||||
field :authenticated_at, :utc_datetime, virtual: true
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for registering or changing the email.
|
||||
|
||||
It requires the email to change otherwise an error is added.
|
||||
|
||||
## Options
|
||||
|
||||
* `:validate_unique` - Set to false if you don't want to validate the
|
||||
uniqueness of the email, useful when displaying live validations.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def email_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email])
|
||||
|> validate_email(opts)
|
||||
end
|
||||
|
||||
defp validate_email(changeset, opts) do
|
||||
changeset =
|
||||
changeset
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
|
||||
message: "must have the @ sign and no spaces"
|
||||
)
|
||||
|> validate_length(:email, max: 160)
|
||||
|
||||
if Keyword.get(opts, :validate_unique, true) do
|
||||
changeset
|
||||
|> unsafe_validate_unique(:email, BeetRoundServer.Repo)
|
||||
|> unique_constraint(:email)
|
||||
|> validate_email_changed()
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_email_changed(changeset) do
|
||||
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
|
||||
add_error(changeset, :email, "did not change")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the password.
|
||||
|
||||
It is important to validate the length of the password, as long passwords may
|
||||
be very expensive to hash for certain algorithms.
|
||||
|
||||
## Options
|
||||
|
||||
* `:hash_password` - Hashes the password so it can be stored securely
|
||||
in the database and ensures the password field is cleared to prevent
|
||||
leaks in the logs. If password hashing is not needed and clearing the
|
||||
password field is not desired (like when using this changeset for
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def password_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:password])
|
||||
|> validate_confirmation(:password, message: "does not match password")
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
defp validate_password(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:password])
|
||||
|> validate_length(:password, min: 12, max: 72)
|
||||
# Examples of additional password validation:
|
||||
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
|
||||
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
|
||||
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|
||||
|> maybe_hash_password(opts)
|
||||
end
|
||||
|
||||
defp maybe_hash_password(changeset, opts) do
|
||||
hash_password? = Keyword.get(opts, :hash_password, true)
|
||||
password = get_change(changeset, :password)
|
||||
|
||||
if hash_password? && password && changeset.valid? do
|
||||
changeset
|
||||
# If using Bcrypt, then further validate it is at most 72 bytes long
|
||||
|> validate_length(:password, max: 72, count: :bytes)
|
||||
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
|
||||
# would keep the database transaction open longer and hurt performance.
|
||||
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|
||||
|> delete_change(:password)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms the account by setting `confirmed_at`.
|
||||
"""
|
||||
def confirm_changeset(user) do
|
||||
now = DateTime.utc_now(:second)
|
||||
change(user, confirmed_at: now)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies the password.
|
||||
|
||||
If there is no user or the user doesn't have a password, we call
|
||||
`Bcrypt.no_user_verify/0` to avoid timing attacks.
|
||||
"""
|
||||
def valid_password?(%BeetRoundServer.Accounts.User{hashed_password: hashed_password}, password)
|
||||
when is_binary(hashed_password) and byte_size(password) > 0 do
|
||||
Bcrypt.verify_pass(password, hashed_password)
|
||||
end
|
||||
|
||||
def valid_password?(_, _) do
|
||||
Bcrypt.no_user_verify()
|
||||
false
|
||||
end
|
||||
end
|
||||
13
lib/beet_round_server/accounts/user_email.ex
Normal file
@ -0,0 +1,13 @@
|
||||
defmodule BeetRoundServer.UserEmail do
|
||||
use Phoenix.Swoosh,
|
||||
template_root: "lib/beet_round_server_web/templates/emails",
|
||||
template_path: "invite"
|
||||
|
||||
def invite(user) do
|
||||
new()
|
||||
|> to({user.name, user.email})
|
||||
|> from({"Das Grüne Zebra e.V.", "bietrunde@das-gruene-zebra.de"})
|
||||
|> subject("Bietrunde 26/27 - Digitales Bieten")
|
||||
|> render_body("invite.html", %{name: user.name, invite_link: user.access_url})
|
||||
end
|
||||
end
|
||||
84
lib/beet_round_server/accounts/user_notifier.ex
Normal file
@ -0,0 +1,84 @@
|
||||
defmodule BeetRoundServer.Accounts.UserNotifier do
|
||||
import Swoosh.Email
|
||||
|
||||
alias BeetRoundServer.Mailer
|
||||
alias BeetRoundServer.Accounts.User
|
||||
|
||||
# Delivers the email using the application mailer.
|
||||
defp deliver(recipient, subject, body) do
|
||||
email =
|
||||
new()
|
||||
|> to(recipient)
|
||||
|> from({"BeetRoundServer", "contact@example.com"})
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
with {:ok, _metadata} <- Mailer.deliver(email) do
|
||||
{:ok, email}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to update a user email.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
deliver(user.email, "Update email instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can change your email by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to log in with a magic link.
|
||||
"""
|
||||
def deliver_login_instructions(user, url) do
|
||||
case user do
|
||||
%User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url)
|
||||
_ -> deliver_magic_link_instructions(user, url)
|
||||
end
|
||||
end
|
||||
|
||||
defp deliver_magic_link_instructions(user, url) do
|
||||
deliver(user.email, "Log in instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can log into your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this email, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
defp deliver_confirmation_instructions(user, url) do
|
||||
deliver(user.email, "Confirmation instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can confirm your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't create an account with us, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
end
|
||||
179
lib/beet_round_server/accounts/user_token.ex
Normal file
@ -0,0 +1,179 @@
|
||||
defmodule BeetRoundServer.Accounts.UserToken do
|
||||
use Ecto.Schema
|
||||
import Ecto.Query
|
||||
alias BeetRoundServer.Accounts.UserToken
|
||||
|
||||
@hash_algorithm :sha256
|
||||
@rand_size 32
|
||||
|
||||
# It is very important to keep the magic link token expiry short,
|
||||
# since someone with access to the email may take over the account.
|
||||
@magic_link_validity_in_minutes 15
|
||||
@change_email_validity_in_days 7
|
||||
@session_validity_in_days 14
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "users_tokens" do
|
||||
field :token, :binary
|
||||
field :context, :string
|
||||
field :sent_to, :string
|
||||
field :authenticated_at, :utc_datetime
|
||||
belongs_to :user, BeetRoundServer.Accounts.User
|
||||
|
||||
timestamps(type: :utc_datetime, updated_at: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a token that will be stored in a signed place,
|
||||
such as session or cookie. As they are signed, those
|
||||
tokens do not need to be hashed.
|
||||
|
||||
The reason why we store session tokens in the database, even
|
||||
though Phoenix already provides a session cookie, is because
|
||||
Phoenix's default session cookies are not persisted, they are
|
||||
simply signed and potentially encrypted. This means they are
|
||||
valid indefinitely, unless you change the signing/encryption
|
||||
salt.
|
||||
|
||||
Therefore, storing them allows individual user
|
||||
sessions to be expired. The token system can also be extended
|
||||
to store additional data, such as the device used for logging in.
|
||||
You could then use this information to display all valid sessions
|
||||
and devices in the UI and allow users to explicitly expire any
|
||||
session they deem invalid.
|
||||
"""
|
||||
def build_session_token(user) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
dt = user.authenticated_at || DateTime.utc_now(:second)
|
||||
{token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user found by the token, if any, along with the token's creation time.
|
||||
|
||||
The token is valid if it matches the value in the database and it has
|
||||
not expired (after @session_validity_in_days).
|
||||
"""
|
||||
def verify_session_token_query(token) do
|
||||
query =
|
||||
from token in by_token_and_context_query(token, "session"),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(@session_validity_in_days, "day"),
|
||||
select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
|
||||
|
||||
{:ok, query}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds a token and its hash to be delivered to the user's email.
|
||||
|
||||
The non-hashed token is sent to the user email while the
|
||||
hashed part is stored in the database. The original token cannot be reconstructed,
|
||||
which means anyone with read-only access to the database cannot directly use
|
||||
the token in the application to gain access. Furthermore, if the user changes
|
||||
their email in the system, the tokens sent to the previous email are no longer
|
||||
valid.
|
||||
|
||||
Users can easily adapt the existing code to provide other types of delivery methods,
|
||||
for example, by phone numbers.
|
||||
"""
|
||||
def build_email_token(user, context) do
|
||||
build_hashed_token(user, context, user.email)
|
||||
end
|
||||
|
||||
defp build_hashed_token(user, context, sent_to) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
hashed_token = :crypto.hash(@hash_algorithm, token)
|
||||
|
||||
{Base.url_encode64(token, padding: false),
|
||||
%UserToken{
|
||||
token: hashed_token,
|
||||
context: context,
|
||||
sent_to: sent_to,
|
||||
user_id: user.id
|
||||
}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
If found, the query returns a tuple of the form `{user, token}`.
|
||||
|
||||
The given token is valid if it matches its hashed counterpart in the
|
||||
database. This function also checks if the token is being used within
|
||||
15 minutes. The context of a magic link token is always "login".
|
||||
"""
|
||||
def verify_magic_link_token_query(token) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
|
||||
query =
|
||||
from token in by_token_and_context_query(hashed_token, "login"),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
|
||||
where: token.sent_to == user.email,
|
||||
select: {user, token}
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
def verify_email_token_query(token, context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
days = days_for_context(context)
|
||||
|
||||
query =
|
||||
from token in by_token_and_context_query(hashed_token, context),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
|
||||
select: user
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp days_for_context("session"), do: @session_validity_in_days
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user_token found by the token, if any.
|
||||
|
||||
This is used to validate requests to change the user
|
||||
email.
|
||||
The given token is valid if it matches its hashed counterpart in the
|
||||
database and if it has not expired (after @change_email_validity_in_days).
|
||||
The context must always start with "change:".
|
||||
"""
|
||||
def verify_change_email_token_query(token, "change:" <> _ = context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
|
||||
query =
|
||||
from token in by_token_and_context_query(hashed_token, context),
|
||||
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp by_token_and_context_query(token, context) do
|
||||
from UserToken, where: [token: ^token, context: ^context]
|
||||
end
|
||||
end
|
||||
328
lib/beet_round_server/admins.ex
Normal file
@ -0,0 +1,328 @@
|
||||
defmodule BeetRoundServer.Admins do
|
||||
@moduledoc """
|
||||
The Admins context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias BeetRoundServer.Repo
|
||||
|
||||
alias BeetRoundServer.Admins.{Admin, AdminToken, AdminNotifier}
|
||||
|
||||
## Database getters
|
||||
|
||||
@doc """
|
||||
Gets a admin by email.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_admin_by_email("foo@example.com")
|
||||
%Admin{}
|
||||
|
||||
iex> get_admin_by_email("unknown@example.com")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_admin_by_email(email) when is_binary(email) do
|
||||
Repo.get_by(Admin, email: email)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a admin by email and password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_admin_by_email_and_password("foo@example.com", "correct_password")
|
||||
%Admin{}
|
||||
|
||||
iex> get_admin_by_email_and_password("foo@example.com", "invalid_password")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_admin_by_email_and_password(email, password)
|
||||
when is_binary(email) and is_binary(password) do
|
||||
admin = Repo.get_by(Admin, email: email)
|
||||
if Admin.valid_password?(admin, password), do: admin
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single admin.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Admin does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_admin!(123)
|
||||
%Admin{}
|
||||
|
||||
iex> get_admin!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_admin!(id), do: Repo.get!(Admin, id)
|
||||
|
||||
## Admin registration
|
||||
|
||||
@doc """
|
||||
Registers a admin.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> register_admin(%{field: value})
|
||||
{:ok, %Admin{}}
|
||||
|
||||
iex> register_admin(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def register_admin(attrs) do
|
||||
%Admin{}
|
||||
|> Admin.email_changeset(attrs)
|
||||
|> Admin.password_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
## Settings
|
||||
|
||||
@doc """
|
||||
Checks whether the admin is in sudo mode.
|
||||
|
||||
The admin is in sudo mode when the last authentication was done no further
|
||||
than 20 minutes ago. The limit can be given as second argument in minutes.
|
||||
"""
|
||||
def sudo_mode?(admin, minutes \\ -20)
|
||||
|
||||
def sudo_mode?(%Admin{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
|
||||
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
|
||||
end
|
||||
|
||||
def sudo_mode?(_admin, _minutes), do: false
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the admin email.
|
||||
|
||||
See `BeetRoundServer.Admins.Admin.email_changeset/3` for a list of supported options.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_admin_email(admin)
|
||||
%Ecto.Changeset{data: %Admin{}}
|
||||
|
||||
"""
|
||||
def change_admin_email(admin, attrs \\ %{}, opts \\ []) do
|
||||
Admin.email_changeset(admin, attrs, opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the admin email using the given token.
|
||||
|
||||
If the token matches, the admin email is updated and the token is deleted.
|
||||
"""
|
||||
def update_admin_email(admin, token) do
|
||||
context = "change:#{admin.email}"
|
||||
|
||||
Repo.transact(fn ->
|
||||
with {:ok, query} <- AdminToken.verify_change_email_token_query(token, context),
|
||||
%AdminToken{sent_to: email} <- Repo.one(query),
|
||||
{:ok, admin} <- Repo.update(Admin.email_changeset(admin, %{email: email})),
|
||||
{_count, _result} <-
|
||||
Repo.delete_all(from(AdminToken, where: [admin_id: ^admin.id, context: ^context])) do
|
||||
{:ok, admin}
|
||||
else
|
||||
_ -> {:error, :transaction_aborted}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the admin password.
|
||||
|
||||
See `BeetRoundServer.Admins.Admin.password_changeset/3` for a list of supported options.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_admin_password(admin)
|
||||
%Ecto.Changeset{data: %Admin{}}
|
||||
|
||||
"""
|
||||
def change_admin_password(admin, attrs \\ %{}, opts \\ []) do
|
||||
Admin.password_changeset(admin, attrs, opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the admin password.
|
||||
|
||||
Returns a tuple with the updated admin, as well as a list of expired tokens.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_admin_password(admin, %{password: ...})
|
||||
{:ok, {%Admin{}, [...]}}
|
||||
|
||||
iex> update_admin_password(admin, %{password: "too short"})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_admin_password(admin, attrs) do
|
||||
admin
|
||||
|> Admin.password_changeset(attrs)
|
||||
|> update_admin_and_delete_all_tokens()
|
||||
end
|
||||
|
||||
## Session
|
||||
|
||||
@doc """
|
||||
Generates a session token.
|
||||
"""
|
||||
def generate_admin_session_token(admin) do
|
||||
{token, admin_token} = AdminToken.build_session_token(admin)
|
||||
Repo.insert!(admin_token)
|
||||
token
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the admin with the given signed token.
|
||||
|
||||
If the token is valid `{admin, token_inserted_at}` is returned, otherwise `nil` is returned.
|
||||
"""
|
||||
def get_admin_by_session_token(token) do
|
||||
{:ok, query} = AdminToken.verify_session_token_query(token)
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the admin with the given magic link token.
|
||||
"""
|
||||
def get_admin_by_magic_link_token(token) do
|
||||
with {:ok, query} <- AdminToken.verify_magic_link_token_query(token),
|
||||
{admin, _token} <- Repo.one(query) do
|
||||
admin
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the admin in by magic link.
|
||||
|
||||
There are three cases to consider:
|
||||
|
||||
1. The admin has already confirmed their email. They are logged in
|
||||
and the magic link is expired.
|
||||
|
||||
2. The admin has not confirmed their email and no password is set.
|
||||
In this case, the admin gets confirmed, logged in, and all tokens -
|
||||
including session ones - are expired. In theory, no other tokens
|
||||
exist but we delete all of them for best security practices.
|
||||
|
||||
3. The admin has not confirmed their email but a password is set.
|
||||
This cannot happen in the default implementation but may be the
|
||||
source of security pitfalls. See the "Mixing magic link and password registration" section of
|
||||
`mix help phx.gen.auth`.
|
||||
"""
|
||||
def login_admin_by_magic_link(token) do
|
||||
{:ok, query} = AdminToken.verify_magic_link_token_query(token)
|
||||
|
||||
case Repo.one(query) do
|
||||
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
|
||||
{%Admin{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
|
||||
raise """
|
||||
magic link log in is not allowed for unconfirmed users with a password set!
|
||||
|
||||
This cannot happen with the default implementation, which indicates that you
|
||||
might have adapted the code to a different use case. Please make sure to read the
|
||||
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
|
||||
"""
|
||||
|
||||
{%Admin{confirmed_at: nil} = admin, _token} ->
|
||||
admin
|
||||
|> Admin.confirm_changeset()
|
||||
|> update_admin_and_delete_all_tokens()
|
||||
|
||||
{admin, token} ->
|
||||
Repo.delete!(token)
|
||||
{:ok, {admin, []}}
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Delivers the update email instructions to the given admin.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_admin_update_email_instructions(admin, current_email, &url(~p"/admins/settings/confirm-email/#{&1}"))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_admin_update_email_instructions(
|
||||
%Admin{} = admin,
|
||||
current_email,
|
||||
update_email_url_fun
|
||||
)
|
||||
when is_function(update_email_url_fun, 1) do
|
||||
{encoded_token, admin_token} = AdminToken.build_email_token(admin, "change:#{current_email}")
|
||||
|
||||
Repo.insert!(admin_token)
|
||||
AdminNotifier.deliver_update_email_instructions(admin, update_email_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delivers the magic link login instructions to the given admin.
|
||||
"""
|
||||
def deliver_login_instructions(%Admin{} = admin, magic_link_url_fun)
|
||||
when is_function(magic_link_url_fun, 1) do
|
||||
{encoded_token, admin_token} = AdminToken.build_email_token(admin, "login")
|
||||
Repo.insert!(admin_token)
|
||||
AdminNotifier.deliver_login_instructions(admin, magic_link_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes the signed token with the given context.
|
||||
"""
|
||||
def delete_admin_session_token(token) do
|
||||
Repo.delete_all(from(AdminToken, where: [token: ^token, context: "session"]))
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new api token for an admin.
|
||||
|
||||
The token returned must be saved somewhere safe.
|
||||
This token cannot be recovered from the database.
|
||||
"""
|
||||
def create_admin_api_token(admin) do
|
||||
{encoded_token, admin_token} = AdminToken.build_email_token(admin, "api-token")
|
||||
Repo.insert!(admin_token)
|
||||
encoded_token
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches the admin by API token.
|
||||
"""
|
||||
def fetch_admin_by_api_token(token) do
|
||||
with {:ok, query} <- AdminToken.verify_email_token_query(token, "api-token"),
|
||||
%Admin{} = admin <- Repo.one(query) do
|
||||
{:ok, admin}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
## Token helper
|
||||
|
||||
defp update_admin_and_delete_all_tokens(changeset) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, admin} <- Repo.update(changeset) do
|
||||
tokens_to_expire = Repo.all_by(AdminToken, admin_id: admin.id)
|
||||
|
||||
Repo.delete_all(
|
||||
from(t in AdminToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id))
|
||||
)
|
||||
|
||||
{:ok, {admin, tokens_to_expire}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
134
lib/beet_round_server/admins/admin.ex
Normal file
@ -0,0 +1,134 @@
|
||||
defmodule BeetRoundServer.Admins.Admin do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "admins" do
|
||||
field :email, :string
|
||||
field :password, :string, virtual: true, redact: true
|
||||
field :hashed_password, :string, redact: true
|
||||
field :confirmed_at, :utc_datetime
|
||||
field :authenticated_at, :utc_datetime, virtual: true
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc """
|
||||
A admin changeset for registering or changing the email.
|
||||
|
||||
It requires the email to change otherwise an error is added.
|
||||
|
||||
## Options
|
||||
|
||||
* `:validate_unique` - Set to false if you don't want to validate the
|
||||
uniqueness of the email, useful when displaying live validations.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def email_changeset(admin, attrs, opts \\ []) do
|
||||
admin
|
||||
|> cast(attrs, [:email])
|
||||
|> validate_email(opts)
|
||||
end
|
||||
|
||||
defp validate_email(changeset, opts) do
|
||||
changeset =
|
||||
changeset
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
|
||||
message: "must have the @ sign and no spaces"
|
||||
)
|
||||
|> validate_length(:email, max: 160)
|
||||
|
||||
if Keyword.get(opts, :validate_unique, true) do
|
||||
changeset
|
||||
|> unsafe_validate_unique(:email, BeetRoundServer.Repo)
|
||||
|> unique_constraint(:email)
|
||||
|> validate_email_changed()
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_email_changed(changeset) do
|
||||
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
|
||||
add_error(changeset, :email, "did not change")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A admin changeset for changing the password.
|
||||
|
||||
It is important to validate the length of the password, as long passwords may
|
||||
be very expensive to hash for certain algorithms.
|
||||
|
||||
## Options
|
||||
|
||||
* `:hash_password` - Hashes the password so it can be stored securely
|
||||
in the database and ensures the password field is cleared to prevent
|
||||
leaks in the logs. If password hashing is not needed and clearing the
|
||||
password field is not desired (like when using this changeset for
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def password_changeset(admin, attrs, opts \\ []) do
|
||||
admin
|
||||
|> cast(attrs, [:password])
|
||||
|> validate_confirmation(:password, message: "does not match password")
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
defp validate_password(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:password])
|
||||
|> validate_length(:password, min: 12, max: 72)
|
||||
# Examples of additional password validation:
|
||||
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
|
||||
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
|
||||
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|
||||
|> maybe_hash_password(opts)
|
||||
end
|
||||
|
||||
defp maybe_hash_password(changeset, opts) do
|
||||
hash_password? = Keyword.get(opts, :hash_password, true)
|
||||
password = get_change(changeset, :password)
|
||||
|
||||
if hash_password? && password && changeset.valid? do
|
||||
changeset
|
||||
# If using Bcrypt, then further validate it is at most 72 bytes long
|
||||
|> validate_length(:password, max: 72, count: :bytes)
|
||||
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
|
||||
# would keep the database transaction open longer and hurt performance.
|
||||
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|
||||
|> delete_change(:password)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms the account by setting `confirmed_at`.
|
||||
"""
|
||||
def confirm_changeset(admin) do
|
||||
now = DateTime.utc_now(:second)
|
||||
change(admin, confirmed_at: now)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies the password.
|
||||
|
||||
If there is no admin or the admin doesn't have a password, we call
|
||||
`Bcrypt.no_user_verify/0` to avoid timing attacks.
|
||||
"""
|
||||
def valid_password?(%BeetRoundServer.Admins.Admin{hashed_password: hashed_password}, password)
|
||||
when is_binary(hashed_password) and byte_size(password) > 0 do
|
||||
Bcrypt.verify_pass(password, hashed_password)
|
||||
end
|
||||
|
||||
def valid_password?(_, _) do
|
||||
Bcrypt.no_user_verify()
|
||||
false
|
||||
end
|
||||
end
|
||||
84
lib/beet_round_server/admins/admin_notifier.ex
Normal file
@ -0,0 +1,84 @@
|
||||
defmodule BeetRoundServer.Admins.AdminNotifier do
|
||||
import Swoosh.Email
|
||||
|
||||
alias BeetRoundServer.Mailer
|
||||
alias BeetRoundServer.Admins.Admin
|
||||
|
||||
# Delivers the email using the application mailer.
|
||||
defp deliver(recipient, subject, body) do
|
||||
email =
|
||||
new()
|
||||
|> to(recipient)
|
||||
|> from({"BeetRoundServer", "contact@example.com"})
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
with {:ok, _metadata} <- Mailer.deliver(email) do
|
||||
{:ok, email}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to update a admin email.
|
||||
"""
|
||||
def deliver_update_email_instructions(admin, url) do
|
||||
deliver(admin.email, "Update email instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{admin.email},
|
||||
|
||||
You can change your email by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to log in with a magic link.
|
||||
"""
|
||||
def deliver_login_instructions(admin, url) do
|
||||
case admin do
|
||||
%Admin{confirmed_at: nil} -> deliver_confirmation_instructions(admin, url)
|
||||
_ -> deliver_magic_link_instructions(admin, url)
|
||||
end
|
||||
end
|
||||
|
||||
defp deliver_magic_link_instructions(admin, url) do
|
||||
deliver(admin.email, "Log in instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{admin.email},
|
||||
|
||||
You can log into your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this email, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
defp deliver_confirmation_instructions(admin, url) do
|
||||
deliver(admin.email, "Confirmation instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{admin.email},
|
||||
|
||||
You can confirm your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't create an account with us, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
end
|
||||
195
lib/beet_round_server/admins/admin_token.ex
Normal file
@ -0,0 +1,195 @@
|
||||
defmodule BeetRoundServer.Admins.AdminToken do
|
||||
use Ecto.Schema
|
||||
import Ecto.Query
|
||||
alias BeetRoundServer.Admins.AdminToken
|
||||
|
||||
@hash_algorithm :sha256
|
||||
@rand_size 32
|
||||
|
||||
# It is very important to keep the magic link token expiry short,
|
||||
# since someone with access to the email may take over the account.
|
||||
@magic_link_validity_in_minutes 15
|
||||
@change_email_validity_in_days 7
|
||||
@session_validity_in_days 14
|
||||
@api_validity_in_days 30
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "admins_tokens" do
|
||||
field :token, :binary
|
||||
field :context, :string
|
||||
field :sent_to, :string
|
||||
field :authenticated_at, :utc_datetime
|
||||
belongs_to :admin, BeetRoundServer.Admins.Admin
|
||||
|
||||
timestamps(type: :utc_datetime, updated_at: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a token that will be stored in a signed place,
|
||||
such as session or cookie. As they are signed, those
|
||||
tokens do not need to be hashed.
|
||||
|
||||
The reason why we store session tokens in the database, even
|
||||
though Phoenix already provides a session cookie, is because
|
||||
Phoenix's default session cookies are not persisted, they are
|
||||
simply signed and potentially encrypted. This means they are
|
||||
valid indefinitely, unless you change the signing/encryption
|
||||
salt.
|
||||
|
||||
Therefore, storing them allows individual admin
|
||||
sessions to be expired. The token system can also be extended
|
||||
to store additional data, such as the device used for logging in.
|
||||
You could then use this information to display all valid sessions
|
||||
and devices in the UI and allow users to explicitly expire any
|
||||
session they deem invalid.
|
||||
"""
|
||||
def build_session_token(admin) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
dt = admin.authenticated_at || DateTime.utc_now(:second)
|
||||
|
||||
{token,
|
||||
%AdminToken{token: token, context: "session", admin_id: admin.id, authenticated_at: dt}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the admin found by the token, if any, along with the token's creation time.
|
||||
|
||||
The token is valid if it matches the value in the database and it has
|
||||
not expired (after @session_validity_in_days).
|
||||
"""
|
||||
def verify_session_token_query(token) do
|
||||
query =
|
||||
from token in by_token_and_context_query(token, "session"),
|
||||
join: admin in assoc(token, :admin),
|
||||
where: token.inserted_at > ago(@session_validity_in_days, "day"),
|
||||
select: {%{admin | authenticated_at: token.authenticated_at}, token.inserted_at}
|
||||
|
||||
{:ok, query}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds a token and its hash to be delivered to the admin's email.
|
||||
|
||||
The non-hashed token is sent to the admin email while the
|
||||
hashed part is stored in the database. The original token cannot be reconstructed,
|
||||
which means anyone with read-only access to the database cannot directly use
|
||||
the token in the application to gain access. Furthermore, if the admin changes
|
||||
their email in the system, the tokens sent to the previous email are no longer
|
||||
valid.
|
||||
|
||||
Users can easily adapt the existing code to provide other types of delivery methods,
|
||||
for example, by phone numbers.
|
||||
"""
|
||||
def build_email_token(admin, context) do
|
||||
build_hashed_token(admin, context, admin.email)
|
||||
end
|
||||
|
||||
defp build_hashed_token(admin, context, sent_to) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
hashed_token = :crypto.hash(@hash_algorithm, token)
|
||||
|
||||
{Base.url_encode64(token, padding: false),
|
||||
%AdminToken{
|
||||
token: hashed_token,
|
||||
context: context,
|
||||
sent_to: sent_to,
|
||||
admin_id: admin.id
|
||||
}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
If found, the query returns a tuple of the form `{admin, token}`.
|
||||
|
||||
The given token is valid if it matches its hashed counterpart in the
|
||||
database. This function also checks if the token is being used within
|
||||
15 minutes. The context of a magic link token is always "login".
|
||||
"""
|
||||
def verify_magic_link_token_query(token) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
|
||||
query =
|
||||
from token in by_token_and_context_query(hashed_token, "login"),
|
||||
join: admin in assoc(token, :admin),
|
||||
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
|
||||
where: token.sent_to == admin.email,
|
||||
select: {admin, token}
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the admin_token found by the token, if any.
|
||||
|
||||
This is used to validate requests to change the admin
|
||||
email.
|
||||
The given token is valid if it matches its hashed counterpart in the
|
||||
database and if it has not expired (after @change_email_validity_in_days).
|
||||
The context must always start with "change:".
|
||||
"""
|
||||
def verify_change_email_token_query(token, "change:" <> _ = context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
|
||||
query =
|
||||
from token in by_token_and_context_query(hashed_token, context),
|
||||
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the admin found by the token, if any.
|
||||
|
||||
The given token is valid if it matches its hashed counterpart in the
|
||||
database and the user email has not changed. This function also checks
|
||||
if the token is being used within a certain period, depending on the
|
||||
context. The default contexts supported by this function are either
|
||||
"confirm", for account confirmation emails, and "reset_password",
|
||||
for resetting the password. For verifying requests to change the email,
|
||||
see `verify_change_email_token_query/2`.
|
||||
"""
|
||||
def verify_email_token_query(token, context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
days = days_for_context(context)
|
||||
|
||||
query =
|
||||
from token in by_token_and_context_query(hashed_token, context),
|
||||
join: admin in assoc(token, :admin),
|
||||
where: token.inserted_at > ago(^days, "day") and token.sent_to == admin.email,
|
||||
select: admin
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp days_for_context("api-token"), do: @api_validity_in_days
|
||||
|
||||
defp by_token_and_context_query(token, context) do
|
||||
from AdminToken, where: [token: ^token, context: ^context]
|
||||
end
|
||||
end
|
||||
33
lib/beet_round_server/admins/scope.ex
Normal file
@ -0,0 +1,33 @@
|
||||
defmodule BeetRoundServer.Admins.Scope do
|
||||
@moduledoc """
|
||||
Defines the scope of the caller to be used throughout the app.
|
||||
|
||||
The `BeetRoundServer.Admins.Scope` allows public interfaces to receive
|
||||
information about the caller, such as if the call is initiated from an
|
||||
end-user, and if so, which user. Additionally, such a scope can carry fields
|
||||
such as "super user" or other privileges for use as authorization, or to
|
||||
ensure specific code paths can only be access for a given scope.
|
||||
|
||||
It is useful for logging as well as for scoping pubsub subscriptions and
|
||||
broadcasts when a caller subscribes to an interface or performs a particular
|
||||
action.
|
||||
|
||||
Feel free to extend the fields on this struct to fit the needs of
|
||||
growing application requirements.
|
||||
"""
|
||||
|
||||
alias BeetRoundServer.Admins.Admin
|
||||
|
||||
defstruct admin: nil
|
||||
|
||||
@doc """
|
||||
Creates a scope for the given admin.
|
||||
|
||||
Returns nil if no admin is given.
|
||||
"""
|
||||
def for_admin(%Admin{} = admin) do
|
||||
%__MODULE__{admin: admin}
|
||||
end
|
||||
|
||||
def for_admin(nil), do: nil
|
||||
end
|
||||
34
lib/beet_round_server/application.ex
Normal file
@ -0,0 +1,34 @@
|
||||
defmodule BeetRoundServer.Application do
|
||||
# See https://hexdocs.pm/elixir/Application.html
|
||||
# for more information on OTP Applications
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
BeetRoundServerWeb.Telemetry,
|
||||
BeetRoundServer.Repo,
|
||||
{DNSCluster, query: Application.get_env(:beet_round_server, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: BeetRoundServer.PubSub},
|
||||
# Start a worker by calling: BeetRoundServer.Worker.start_link(arg)
|
||||
# {BeetRoundServer.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
BeetRoundServerWeb.Endpoint
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: BeetRoundServer.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
# Tell Phoenix to update the endpoint configuration
|
||||
# whenever the application is updated.
|
||||
@impl true
|
||||
def config_change(changed, _new, removed) do
|
||||
BeetRoundServerWeb.Endpoint.config_change(changed, removed)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
125
lib/beet_round_server/bidding_rounds.ex
Normal file
@ -0,0 +1,125 @@
|
||||
defmodule BeetRoundServer.BiddingRounds do
|
||||
@moduledoc """
|
||||
The BiddingRounds context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias BeetRoundServer.Repo
|
||||
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRound
|
||||
|
||||
@doc """
|
||||
Returns the list of bidding_rounds.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_bidding_rounds()
|
||||
[%BiddingRound{}, ...]
|
||||
|
||||
"""
|
||||
def list_bidding_rounds do
|
||||
Repo.all(BiddingRound)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single bidding_round.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Bidding round does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_bidding_round!(123)
|
||||
%BiddingRound{}
|
||||
|
||||
iex> get_bidding_round!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_bidding_round!(id), do: Repo.get!(BiddingRound, id)
|
||||
|
||||
def get_highest_bidding_round!() do
|
||||
query =
|
||||
Ecto.Query.from(bidding_round in BiddingRound,
|
||||
order_by: [desc: bidding_round.round_number],
|
||||
limit: 1
|
||||
)
|
||||
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
def get_bidding_round_by_number!(round_number) do
|
||||
query =
|
||||
Ecto.Query.from(bidding_round in BiddingRound,
|
||||
where: bidding_round.round_number == ^round_number,
|
||||
order_by: [desc: bidding_round.inserted_at],
|
||||
limit: 1
|
||||
)
|
||||
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a bidding_round.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_bidding_round(%{field: value})
|
||||
{:ok, %BiddingRound{}}
|
||||
|
||||
iex> create_bidding_round(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_bidding_round(attrs) do
|
||||
%BiddingRound{}
|
||||
|> BiddingRound.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a bidding_round.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_bidding_round(bidding_round, %{field: new_value})
|
||||
{:ok, %BiddingRound{}}
|
||||
|
||||
iex> update_bidding_round(bidding_round, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_bidding_round(%BiddingRound{} = bidding_round, attrs) do
|
||||
bidding_round
|
||||
|> BiddingRound.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a bidding_round.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_bidding_round(bidding_round)
|
||||
{:ok, %BiddingRound{}}
|
||||
|
||||
iex> delete_bidding_round(bidding_round)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_bidding_round(%BiddingRound{} = bidding_round) do
|
||||
Repo.delete(bidding_round)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking bidding_round changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_bidding_round(bidding_round)
|
||||
%Ecto.Changeset{data: %BiddingRound{}}
|
||||
|
||||
"""
|
||||
def change_bidding_round(%BiddingRound{} = bidding_round, attrs \\ %{}) do
|
||||
BiddingRound.changeset(bidding_round, attrs)
|
||||
end
|
||||
end
|
||||
21
lib/beet_round_server/bidding_rounds/bidding_round.ex
Normal file
@ -0,0 +1,21 @@
|
||||
defmodule BeetRoundServer.BiddingRounds.BiddingRound do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "bidding_rounds" do
|
||||
field :round_number, :integer
|
||||
field :stopped, :boolean, default: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(bidding_round, attrs) do
|
||||
bidding_round
|
||||
|> cast(attrs, [:round_number, :stopped])
|
||||
|> validate_required([:round_number, :stopped])
|
||||
|> unique_constraint(:round_number)
|
||||
end
|
||||
end
|
||||
118
lib/beet_round_server/bidding_rounds/bidding_round_facade.ex
Normal file
@ -0,0 +1,118 @@
|
||||
defmodule BeetRoundServer.BiddingRounds.BiddingRoundFacade do
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRound
|
||||
alias BeetRoundServer.BiddingRounds
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRoundServer
|
||||
|
||||
def restart_if_necessary() do
|
||||
last_round = get_highest_bidding_round()
|
||||
|
||||
if last_round.stopped == false do
|
||||
IO.puts("There is a last round, that wasn't stopped. Should be running...")
|
||||
|
||||
if !isAlive() do
|
||||
IO.puts("...but it isn't. Restarting last round...")
|
||||
restart_hightest_round()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_highest_bidding_round() do
|
||||
last_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
if last_round != nil do
|
||||
last_round
|
||||
else
|
||||
%BiddingRound{round_number: 0, stopped: true, id: "00000000-0000-0000-0000-000000000000"}
|
||||
end
|
||||
end
|
||||
|
||||
def get_current_round do
|
||||
restart_if_necessary()
|
||||
|
||||
if GenServer.whereis(CurrentRoundServer) == nil do
|
||||
IO.puts("CurrentRoundServer isn't alive. Returning 0...")
|
||||
# %BiddingRound{round_number: 0, stopped: true, id: "00000000-0000-0000-0000-000000000000"}
|
||||
0
|
||||
else
|
||||
GenServer.call(CurrentRoundServer, :val)
|
||||
end
|
||||
end
|
||||
|
||||
def start_new_round() do
|
||||
if isAlive() do
|
||||
IO.puts("CurrentRoundServer is alive! Please stop the server before starting a new round")
|
||||
{:error, "A current round is running! Please stop it, before starting a new round."}
|
||||
else
|
||||
IO.puts("CurrentRoundServer isn't alive. Starting instance...")
|
||||
|
||||
last_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
cond do
|
||||
last_round == nil ->
|
||||
IO.puts("No bidding round found. Starting first round...")
|
||||
|
||||
round_number = 1
|
||||
|
||||
BiddingRoundServer.start(round_number)
|
||||
BiddingRounds.create_bidding_round(%{round_number: round_number})
|
||||
|
||||
last_round.stopped == false ->
|
||||
IO.puts("Last bidding round not stopped. Restarting round...")
|
||||
|
||||
BiddingRoundServer.start(last_round.round_number)
|
||||
|
||||
true ->
|
||||
IO.puts("Last bidding round has stopped. Starting a new round...")
|
||||
|
||||
round_number = last_round.round_number + 1
|
||||
|
||||
BiddingRoundServer.start(round_number)
|
||||
BiddingRounds.create_bidding_round(%{round_number: round_number})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def restart_hightest_round() do
|
||||
IO.puts("Restarting hightest round...")
|
||||
|
||||
if isAlive() do
|
||||
IO.puts("Server is alive. Nothing to do...")
|
||||
IO.puts(["Current round: ", GenServer.call(CurrentRoundServer, :val)])
|
||||
else
|
||||
IO.puts("Server isn't alive. Trying to restart last round.")
|
||||
|
||||
last_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
cond do
|
||||
last_round == nil ->
|
||||
IO.puts("No bidding round found! Can't restart round...")
|
||||
{:error, "No bidding round found! Nothing to restart."}
|
||||
|
||||
true ->
|
||||
IO.puts("Last bidding round found. Restarting...")
|
||||
|
||||
BiddingRoundServer.start(last_round.round_number)
|
||||
BiddingRounds.update_bidding_round(last_round, %{stopped: false})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def stop_current_round() do
|
||||
IO.puts("Stopping current round...")
|
||||
|
||||
if isAlive() do
|
||||
IO.puts("Server is alive. Shutting down and writing to DB...")
|
||||
current_round_number = GenServer.call(CurrentRoundServer, :val)
|
||||
GenServer.stop(CurrentRoundServer)
|
||||
|
||||
current_round = BiddingRounds.get_bidding_round_by_number!(current_round_number)
|
||||
BiddingRounds.update_bidding_round(current_round, %{stopped: true})
|
||||
else
|
||||
IO.puts("Server isn't alive. Nothing to shut down.")
|
||||
end
|
||||
end
|
||||
|
||||
def isAlive() do
|
||||
GenServer.whereis(CurrentRoundServer) != nil
|
||||
end
|
||||
end
|
||||
39
lib/beet_round_server/bidding_rounds/bidding_round_server.ex
Normal file
@ -0,0 +1,39 @@
|
||||
defmodule BeetRoundServer.BiddingRounds.BiddingRoundServer do
|
||||
use GenServer
|
||||
def inc(pid), do: GenServer.cast(pid, :inc)
|
||||
def dec(pid), do: GenServer.cast(pid, :dec)
|
||||
|
||||
def val(pid) do
|
||||
GenServer.call(pid, :val)
|
||||
end
|
||||
|
||||
def stop(pid) do
|
||||
GenServer.stop(pid)
|
||||
end
|
||||
|
||||
def start(initial_val) do
|
||||
GenServer.start(__MODULE__, initial_val, name: CurrentRoundServer)
|
||||
end
|
||||
|
||||
def init(initial_val) do
|
||||
{:ok, initial_val}
|
||||
end
|
||||
|
||||
def terminate(_reason, val) do
|
||||
IO.puts("Stopping bidding round:")
|
||||
IO.puts(val)
|
||||
:ok
|
||||
end
|
||||
|
||||
def handle_cast(:inc, val) do
|
||||
{:noreply, val + 1}
|
||||
end
|
||||
|
||||
def handle_cast(:dec, val) do
|
||||
{:noreply, val - 1}
|
||||
end
|
||||
|
||||
def handle_call(:val, _from, val) do
|
||||
{:reply, val, val}
|
||||
end
|
||||
end
|
||||
171
lib/beet_round_server/biddings.ex
Normal file
@ -0,0 +1,171 @@
|
||||
defmodule BeetRoundServer.Biddings do
|
||||
@moduledoc """
|
||||
The Biddings context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias BeetRoundServer.Repo
|
||||
|
||||
alias BeetRoundServer.Biddings.Bidding
|
||||
alias BeetRoundServer.Accounts.Scope
|
||||
|
||||
@doc """
|
||||
Subscribes to scoped notifications about any bidding changes.
|
||||
|
||||
The broadcasted messages match the pattern:
|
||||
|
||||
* {:created, %Bidding{}}
|
||||
* {:updated, %Bidding{}}
|
||||
* {:deleted, %Bidding{}}
|
||||
|
||||
"""
|
||||
def subscribe_biddings(%Scope{} = scope) do
|
||||
key = scope.user.id
|
||||
|
||||
Phoenix.PubSub.subscribe(BeetRoundServer.PubSub, "user:#{key}:biddings")
|
||||
end
|
||||
|
||||
defp broadcast_bidding(%Scope{} = scope, message) do
|
||||
key = scope.user.id
|
||||
|
||||
Phoenix.PubSub.broadcast(BeetRoundServer.PubSub, "user:#{key}:biddings", message)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of biddings.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_biddings(scope)
|
||||
[%Bidding{}, ...]
|
||||
|
||||
"""
|
||||
def list_biddings(%Scope{} = scope) do
|
||||
Repo.all_by(Bidding, user_id: scope.user.id)
|
||||
end
|
||||
|
||||
def list_biddings() do
|
||||
Repo.all(Bidding)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single bidding.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Bidding does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_bidding!(scope, 123)
|
||||
%Bidding{}
|
||||
|
||||
iex> get_bidding!(scope, 456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_bidding!(%Scope{} = scope, id) do
|
||||
Repo.get_by!(Bidding, id: id, user_id: scope.user.id)
|
||||
end
|
||||
|
||||
def biddings_of_round(round_number) do
|
||||
Repo.all(
|
||||
from(bidding in Bidding,
|
||||
where: bidding.bidding_round == ^round_number,
|
||||
order_by: [asc: bidding.inserted_at]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def get_most_recent_bidding(%Scope{} = scope) do
|
||||
query =
|
||||
Ecto.Query.from(bidding in Bidding,
|
||||
where: bidding.user_id == ^scope.user.id,
|
||||
order_by: [desc: bidding.inserted_at],
|
||||
limit: 1
|
||||
)
|
||||
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a bidding.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_bidding(scope, %{field: value})
|
||||
{:ok, %Bidding{}}
|
||||
|
||||
iex> create_bidding(scope, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_bidding(%Scope{} = scope, attrs) do
|
||||
with {:ok, bidding = %Bidding{}} <-
|
||||
%Bidding{}
|
||||
|> Bidding.changeset(attrs, scope)
|
||||
|> Repo.insert() do
|
||||
broadcast_bidding(scope, {:created, bidding})
|
||||
{:ok, bidding}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a bidding.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_bidding(scope, bidding, %{field: new_value})
|
||||
{:ok, %Bidding{}}
|
||||
|
||||
iex> update_bidding(scope, bidding, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_bidding(%Scope{} = scope, %Bidding{} = bidding, attrs) do
|
||||
true = bidding.user_id == scope.user.id
|
||||
|
||||
with {:ok, bidding = %Bidding{}} <-
|
||||
bidding
|
||||
|> Bidding.changeset(attrs, scope)
|
||||
|> Repo.update() do
|
||||
broadcast_bidding(scope, {:updated, bidding})
|
||||
{:ok, bidding}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a bidding.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_bidding(scope, bidding)
|
||||
{:ok, %Bidding{}}
|
||||
|
||||
iex> delete_bidding(scope, bidding)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_bidding(%Scope{} = scope, %Bidding{} = bidding) do
|
||||
true = bidding.user_id == scope.user.id
|
||||
|
||||
with {:ok, bidding = %Bidding{}} <-
|
||||
Repo.delete(bidding) do
|
||||
broadcast_bidding(scope, {:deleted, bidding})
|
||||
{:ok, bidding}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking bidding changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_bidding(scope, bidding)
|
||||
%Ecto.Changeset{data: %Bidding{}}
|
||||
|
||||
"""
|
||||
def change_bidding(%Scope{} = scope, %Bidding{} = bidding, attrs \\ %{}) do
|
||||
true = bidding.user_id == scope.user.id
|
||||
|
||||
Bidding.changeset(bidding, attrs, scope)
|
||||
end
|
||||
end
|
||||
24
lib/beet_round_server/biddings/bidding.ex
Normal file
@ -0,0 +1,24 @@
|
||||
defmodule BeetRoundServer.Biddings.Bidding do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "biddings" do
|
||||
field :bidding_round, :integer
|
||||
field :amount, :integer
|
||||
field :depot_wish_one, :string
|
||||
field :depot_wish_two, :string
|
||||
field :user_id, :binary_id
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(bidding, attrs, user_scope) do
|
||||
bidding
|
||||
|> cast(attrs, [:bidding_round, :amount, :depot_wish_one, :depot_wish_two])
|
||||
|> validate_required([:bidding_round, :amount, :depot_wish_one, :depot_wish_two])
|
||||
|> put_change(:user_id, user_scope.user.id)
|
||||
end
|
||||
end
|
||||
3
lib/beet_round_server/mailer.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule BeetRoundServer.Mailer do
|
||||
use Swoosh.Mailer, otp_app: :beet_round_server
|
||||
end
|
||||
5
lib/beet_round_server/repo.ex
Normal file
@ -0,0 +1,5 @@
|
||||
defmodule BeetRoundServer.Repo do
|
||||
use Ecto.Repo,
|
||||
otp_app: :beet_round_server,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
end
|
||||
116
lib/beet_round_server_web.ex
Normal file
@ -0,0 +1,116 @@
|
||||
defmodule BeetRoundServerWeb do
|
||||
@moduledoc """
|
||||
The entrypoint for defining your web interface, such
|
||||
as controllers, components, channels, and so on.
|
||||
|
||||
This can be used in your application as:
|
||||
|
||||
use BeetRoundServerWeb, :controller
|
||||
use BeetRoundServerWeb, :html
|
||||
|
||||
The definitions below will be executed for every controller,
|
||||
component, etc, so keep them short and clean, focused
|
||||
on imports, uses and aliases.
|
||||
|
||||
Do NOT define functions inside the quoted expressions
|
||||
below. Instead, define additional modules and import
|
||||
those modules here.
|
||||
"""
|
||||
|
||||
def static_paths,
|
||||
do:
|
||||
~w(assets fonts images favicon.ico apple-touch-icon.png favicon-32x32.png favicon-16x16.png manifest.json robots.txt)
|
||||
|
||||
def router do
|
||||
quote do
|
||||
use Phoenix.Router, helpers: false
|
||||
|
||||
# Import common connection and controller functions to use in pipelines
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
import Phoenix.LiveView.Router
|
||||
end
|
||||
end
|
||||
|
||||
def channel do
|
||||
quote do
|
||||
use Phoenix.Channel
|
||||
end
|
||||
end
|
||||
|
||||
def controller do
|
||||
quote do
|
||||
use Phoenix.Controller, formats: [:html, :json]
|
||||
|
||||
use Gettext, backend: BeetRoundServerWeb.Gettext
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def live_view do
|
||||
quote do
|
||||
use Phoenix.LiveView
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def live_component do
|
||||
quote do
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def html do
|
||||
quote do
|
||||
use Phoenix.Component
|
||||
|
||||
# Import convenience functions from controllers
|
||||
import Phoenix.Controller,
|
||||
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
|
||||
|
||||
# Include general helpers for rendering HTML
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
defp html_helpers do
|
||||
quote do
|
||||
# Translation
|
||||
use Gettext, backend: BeetRoundServerWeb.Gettext
|
||||
|
||||
# HTML escaping functionality
|
||||
import Phoenix.HTML
|
||||
# Core UI components
|
||||
import BeetRoundServerWeb.CoreComponents
|
||||
|
||||
# Common modules used in templates
|
||||
alias Phoenix.LiveView.JS
|
||||
alias BeetRoundServerWeb.Layouts
|
||||
|
||||
# Routes generation with the ~p sigil
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def verified_routes do
|
||||
quote do
|
||||
use Phoenix.VerifiedRoutes,
|
||||
endpoint: BeetRoundServerWeb.Endpoint,
|
||||
router: BeetRoundServerWeb.Router,
|
||||
statics: BeetRoundServerWeb.static_paths()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
When used, dispatch to the appropriate controller/live_view/etc.
|
||||
"""
|
||||
defmacro __using__(which) when is_atom(which) do
|
||||
apply(__MODULE__, which, [])
|
||||
end
|
||||
end
|
||||
231
lib/beet_round_server_web/admin_auth.ex
Normal file
@ -0,0 +1,231 @@
|
||||
defmodule BeetRoundServerWeb.AdminAuth do
|
||||
use BeetRoundServerWeb, :verified_routes
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
|
||||
alias BeetRoundServer.Admins
|
||||
alias BeetRoundServer.Admins.Scope
|
||||
|
||||
# Make the remember me cookie valid for 14 days. This should match
|
||||
# the session validity setting in AdminToken.
|
||||
@max_cookie_age_in_days 14
|
||||
@remember_me_cookie "_beet_round_server_web_admin_remember_me"
|
||||
@remember_me_options [
|
||||
sign: true,
|
||||
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
# How old the session token should be before a new one is issued. When a request is made
|
||||
# with a session token older than this value, then a new session token will be created
|
||||
# and the session and remember-me cookies (if set) will be updated with the new token.
|
||||
# Lowering this value will result in more tokens being created by active users. Increasing
|
||||
# it will result in less time before a session token expires for a user to get issued a new
|
||||
# token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
|
||||
# the reissuing of tokens completely.
|
||||
@session_reissue_age_in_days 7
|
||||
|
||||
@doc """
|
||||
Logs the admin in.
|
||||
|
||||
Redirects to the session's `:admin_return_to` path
|
||||
or falls back to the `signed_in_path/1`.
|
||||
"""
|
||||
def log_in_admin(conn, admin, params \\ %{}) do
|
||||
admin_return_to = get_session(conn, :admin_return_to)
|
||||
|
||||
conn
|
||||
|> create_or_extend_session(admin, params)
|
||||
|> redirect(to: admin_return_to || signed_in_path(conn))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the admin out.
|
||||
|
||||
It clears all session data for safety. See renew_session.
|
||||
"""
|
||||
def log_out_admin(conn) do
|
||||
admin_token = get_session(conn, :admin_token)
|
||||
admin_token && Admins.delete_admin_session_token(admin_token)
|
||||
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
BeetRoundServerWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session(nil)
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates the admin by looking into the session and remember me token.
|
||||
|
||||
Will reissue the session token if it is older than the configured age.
|
||||
"""
|
||||
def fetch_current_scope_for_admin(conn, _opts) do
|
||||
with {token, conn} <- ensure_admin_token(conn),
|
||||
{admin, token_inserted_at} <- Admins.get_admin_by_session_token(token) do
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_admin(admin))
|
||||
|> maybe_reissue_admin_session_token(admin, token_inserted_at)
|
||||
else
|
||||
nil -> assign(conn, :current_scope, Scope.for_admin(nil))
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_admin_token(conn) do
|
||||
if token = get_session(conn, :admin_token) do
|
||||
{token, conn}
|
||||
else
|
||||
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
||||
|
||||
if token = conn.cookies[@remember_me_cookie] do
|
||||
{token, conn |> put_token_in_session(token) |> put_session(:admin_remember_me, true)}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_api_admin(conn, _opts) do
|
||||
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
|
||||
{:ok, admin} <- Admins.fetch_admin_by_api_token(token) do
|
||||
assign(conn, :current_admin, admin)
|
||||
else
|
||||
_ ->
|
||||
conn
|
||||
|> send_resp(:unauthorized, "No access for you!")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
# Reissue the session token if it is older than the configured reissue age.
|
||||
defp maybe_reissue_admin_session_token(conn, admin, token_inserted_at) do
|
||||
token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
|
||||
|
||||
if token_age >= @session_reissue_age_in_days do
|
||||
create_or_extend_session(conn, admin, %{})
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
# This function is the one responsible for creating session tokens
|
||||
# and storing them safely in the session and cookies. It may be called
|
||||
# either when logging in, during sudo mode, or to renew a session which
|
||||
# will soon expire.
|
||||
#
|
||||
# When the session is created, rather than extended, the renew_session
|
||||
# function will clear the session to avoid fixation attacks. See the
|
||||
# renew_session function to customize this behaviour.
|
||||
defp create_or_extend_session(conn, admin, params) do
|
||||
token = Admins.generate_admin_session_token(admin)
|
||||
remember_me = get_session(conn, :admin_remember_me)
|
||||
|
||||
conn
|
||||
|> renew_session(admin)
|
||||
|> put_token_in_session(token)
|
||||
|> maybe_write_remember_me_cookie(token, params, remember_me)
|
||||
end
|
||||
|
||||
# Do not renew session if the admin is already logged in
|
||||
# to prevent CSRF errors or data being lost in tabs that are still open
|
||||
defp renew_session(conn, admin) when conn.assigns.current_scope.admin.id == admin.id do
|
||||
conn
|
||||
end
|
||||
|
||||
# This function renews the session ID and erases the whole
|
||||
# session to avoid fixation attacks. If there is any data
|
||||
# in the session you may want to preserve after log in/log out,
|
||||
# you must explicitly fetch the session data before clearing
|
||||
# and then immediately set it after clearing, for example:
|
||||
#
|
||||
# defp renew_session(conn, _admin) do
|
||||
# delete_csrf_token()
|
||||
# preferred_locale = get_session(conn, :preferred_locale)
|
||||
#
|
||||
# conn
|
||||
# |> configure_session(renew: true)
|
||||
# |> clear_session()
|
||||
# |> put_session(:preferred_locale, preferred_locale)
|
||||
# end
|
||||
#
|
||||
defp renew_session(conn, _admin) do
|
||||
delete_csrf_token()
|
||||
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
|
||||
do: write_remember_me_cookie(conn, token)
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, _params, true),
|
||||
do: write_remember_me_cookie(conn, token)
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
|
||||
|
||||
defp write_remember_me_cookie(conn, token) do
|
||||
conn
|
||||
|> put_session(:admin_remember_me, true)
|
||||
|> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
|
||||
end
|
||||
|
||||
defp put_token_in_session(conn, token) do
|
||||
put_session(conn, :admin_token, token)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Plug for routes that require sudo mode.
|
||||
"""
|
||||
def require_sudo_mode(conn, _opts) do
|
||||
if Admins.sudo_mode?(conn.assigns.current_scope.admin, -10) do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must re-authenticate to access this page.")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: ~p"/admins/log-in")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Plug for routes that require the admin to not be authenticated.
|
||||
"""
|
||||
def redirect_if_admin_is_authenticated(conn, _opts) do
|
||||
if conn.assigns.current_scope do
|
||||
conn
|
||||
|> redirect(to: signed_in_path(conn))
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
defp signed_in_path(_conn), do: ~p"/"
|
||||
|
||||
@doc """
|
||||
Plug for routes that require the admin to be authenticated.
|
||||
"""
|
||||
def require_authenticated_admin(conn, _opts) do
|
||||
if conn.assigns.current_scope && conn.assigns.current_scope.admin do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must log in to access this page.")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: ~p"/admins/log-in")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
||||
put_session(conn, :admin_return_to, current_path(conn))
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(conn), do: conn
|
||||
end
|
||||
496
lib/beet_round_server_web/components/core_components.ex
Normal file
@ -0,0 +1,496 @@
|
||||
defmodule BeetRoundServerWeb.CoreComponents do
|
||||
@moduledoc """
|
||||
Provides core UI components.
|
||||
|
||||
At first glance, this module may seem daunting, but its goal is to provide
|
||||
core building blocks for your application, such as tables, forms, and
|
||||
inputs. The components consist mostly of markup and are well-documented
|
||||
with doc strings and declarative assigns. You may customize and style
|
||||
them in any way you want, based on your application growth and needs.
|
||||
|
||||
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
|
||||
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
|
||||
and themes. Here are useful references:
|
||||
|
||||
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
|
||||
started and see the available components.
|
||||
|
||||
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
|
||||
we build on. You will use it for layout, sizing, flexbox, grid, and
|
||||
spacing.
|
||||
|
||||
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
|
||||
|
||||
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
|
||||
the component system used by Phoenix. Some components, such as `<.link>`
|
||||
and `<.form>`, are defined there.
|
||||
|
||||
"""
|
||||
use Phoenix.Component
|
||||
use Gettext, backend: BeetRoundServerWeb.Gettext
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
|
||||
"""
|
||||
attr :id, :string, doc: "the optional id of flash container"
|
||||
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||
attr :title, :string, default: nil
|
||||
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
||||
def flash(assigns) do
|
||||
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||
id={@id}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
phx-hook=".FlashTimeout"
|
||||
kind={@kind}
|
||||
role="alert"
|
||||
class="toast toast-top toast-end z-50"
|
||||
{@rest}
|
||||
>
|
||||
<div class={[
|
||||
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||
@kind == :info && "alert-info",
|
||||
@kind == :error && "alert-error"
|
||||
]}>
|
||||
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<p :if={@title} class="font-semibold">{@title}</p>
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
<script :type={Phoenix.LiveView.ColocatedHook} name=".FlashTimeout">
|
||||
export default {
|
||||
mounted() {
|
||||
const kind = this.el.getAttribute("kind")
|
||||
|
||||
let timeout = 3000
|
||||
if (kind == "error") {
|
||||
timeout = 9000
|
||||
}
|
||||
let hide = () => liveSocket.execJS(this.el, this.el.getAttribute("phx-click"))
|
||||
this.timer = setTimeout(() => hide(), timeout)
|
||||
|
||||
this.el.addEventListener("phx:hide-start", () => clearTimeout(this.timer))
|
||||
|
||||
this.el.addEventListener("mouseover", () => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => hide(), 9000)
|
||||
})
|
||||
},
|
||||
destroyed() { clearTimeout(this.timer) }
|
||||
};
|
||||
</script>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a button with navigation support.
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" variant="primary">Send!</.button>
|
||||
<.button navigate={~p"/"}>Home</.button>
|
||||
"""
|
||||
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
|
||||
attr :class, :string
|
||||
attr :variant, :string, values: ~w(primary)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(%{rest: rest} = assigns) do
|
||||
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
||||
|
||||
assigns =
|
||||
assign_new(assigns, :class, fn ->
|
||||
["btn", Map.fetch!(variants, assigns[:variant])]
|
||||
end)
|
||||
|
||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||
~H"""
|
||||
<.link class={@class} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
else
|
||||
~H"""
|
||||
<button class={@class} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
A `Phoenix.HTML.FormField` may be passed as argument,
|
||||
which is used to retrieve the input name, id, and values.
|
||||
Otherwise all attributes may be passed explicitly.
|
||||
|
||||
## Types
|
||||
|
||||
This function accepts all HTML input types, considering that:
|
||||
|
||||
* You may also set `type="select"` to render a `<select>` tag
|
||||
|
||||
* `type="checkbox"` is used exclusively to render boolean values
|
||||
|
||||
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
||||
for more information. Unsupported types, such as hidden and radio,
|
||||
are best written directly in your templates.
|
||||
|
||||
## Examples
|
||||
|
||||
<.input field={@form[:email]} type="email" />
|
||||
<.input name="my-input" errors={["oh no!"]} />
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any
|
||||
attr :label, :string, default: nil
|
||||
attr :value, :any
|
||||
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file month number password
|
||||
search select tel text textarea time url week)
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :errors, :list, default: []
|
||||
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||
attr :class, :string, default: nil, doc: "the input class to use over defaults"
|
||||
attr :error_class, :string, default: nil, doc: "the input error class to use over defaults"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
|
||||
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> input()
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||
<span class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class={@class || "checkbox checkbox-sm"}
|
||||
{@rest}
|
||||
/>{@label}
|
||||
</span>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value="">{@prompt}</option>
|
||||
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||
</select>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
@class || "w-full textarea",
|
||||
@errors != [] && (@error_class || "textarea-error")
|
||||
]}
|
||||
{@rest}
|
||||
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
@class || "w-full input",
|
||||
@errors != [] && (@error_class || "input-error")
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper used by inputs to generate form errors
|
||||
defp error(assigns) do
|
||||
~H"""
|
||||
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
"""
|
||||
slot :inner_block, required: true
|
||||
slot :subtitle
|
||||
slot :actions
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8">
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="text-sm text-base-content/70">
|
||||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none">{render_slot(@actions)}</div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a table with generic styling.
|
||||
|
||||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id">{user.id}</:col>
|
||||
<:col :let={user} label="username">{user.username}</:col>
|
||||
</.table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :rows, :list, required: true
|
||||
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||
|
||||
attr :row_item, :any,
|
||||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
end
|
||||
|
||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||
|
||||
def table(assigns) do
|
||||
assigns =
|
||||
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||
end
|
||||
|
||||
~H"""
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
<th :if={@action != []}>
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={@row_click && "hover:cursor-pointer"}
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<div class="flex gap-4">
|
||||
<%= for action <- @action do %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
## Examples
|
||||
|
||||
<.list>
|
||||
<:item title="Title">{@post.title}</:item>
|
||||
<:item title="Views">{@post.views}</:item>
|
||||
</.list>
|
||||
"""
|
||||
slot :item, required: true do
|
||||
attr :title, :string, required: true
|
||||
end
|
||||
|
||||
def list(assigns) do
|
||||
~H"""
|
||||
<ul class="list">
|
||||
<li :for={item <- @item} class="list-row">
|
||||
<div class="list-col-grow">
|
||||
<div class="font-bold">{item.title}</div>
|
||||
<div>{render_slot(item)}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
Heroicons come in three styles – outline, solid, and mini.
|
||||
By default, the outline style is used, but solid and mini may
|
||||
be applied by using the `-solid` and `-mini` suffix.
|
||||
|
||||
You can customize the size and colors of the icons by setting
|
||||
width, height, and background color classes.
|
||||
|
||||
Icons are extracted from the `deps/heroicons` directory and bundled within
|
||||
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.icon name="hero-x-mark" />
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :class, :string, default: "size-4"
|
||||
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
<span class={[@name, @class]} />
|
||||
"""
|
||||
end
|
||||
|
||||
## JS Commands
|
||||
|
||||
def show(js \\ %JS{}, selector) do
|
||||
JS.show(js,
|
||||
to: selector,
|
||||
time: 300,
|
||||
transition:
|
||||
{"transition-all ease-out duration-300",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
|
||||
"opacity-100 translate-y-0 sm:scale-100"}
|
||||
)
|
||||
end
|
||||
|
||||
def hide(js \\ %JS{}, selector) do
|
||||
JS.hide(js,
|
||||
to: selector,
|
||||
time: 200,
|
||||
transition:
|
||||
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates an error message using gettext.
|
||||
"""
|
||||
def translate_error({msg, opts}) do
|
||||
# When using gettext, we typically pass the strings we want
|
||||
# to translate as a static argument:
|
||||
#
|
||||
# # Translate the number of files with plural rules
|
||||
# dngettext("errors", "1 file", "%{count} files", count)
|
||||
#
|
||||
# However the error messages in our forms and APIs are generated
|
||||
# dynamically, so we need to translate them by calling Gettext
|
||||
# with our gettext backend as first argument. Translations are
|
||||
# available in the errors.po file (as we use the "errors" domain).
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(BeetRoundServerWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(BeetRoundServerWeb.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates the errors for a field from a keyword list of errors.
|
||||
"""
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
end
|
||||
143
lib/beet_round_server_web/components/layouts.ex
Normal file
@ -0,0 +1,143 @@
|
||||
defmodule BeetRoundServerWeb.Layouts do
|
||||
@moduledoc """
|
||||
This module holds layouts and related functionality
|
||||
used by your application.
|
||||
"""
|
||||
use BeetRoundServerWeb, :html
|
||||
|
||||
# Embed all files in layouts/* within this module.
|
||||
# The default root.html.heex file contains the HTML
|
||||
# skeleton of your application, namely HTML headers
|
||||
# and other static content.
|
||||
embed_templates "layouts/*"
|
||||
|
||||
@doc """
|
||||
Renders your app layout.
|
||||
|
||||
This function is typically invoked from every template,
|
||||
and it often contains your application menu, sidebar,
|
||||
or similar.
|
||||
|
||||
## Examples
|
||||
|
||||
<Layouts.app flash={@flash}>
|
||||
<h1>Content</h1>
|
||||
</Layouts.app>
|
||||
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
|
||||
attr :current_scope, :map,
|
||||
default: nil,
|
||||
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def app(assigns) do
|
||||
~H"""
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<header class="navbar px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex-1">
|
||||
<a href="/" class="flex-1 flex w-fit items-center gap-2">
|
||||
<img src={~p"/images/BeetRound.png"} width="36" />
|
||||
<span class="text-sm font-semibold">
|
||||
BeetRound v{Application.spec(:beet_round_server, :vsn)} | Das grüne Zebra
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl space-y-4">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows the flash group with standard titles and content.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
||||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id} aria-live="polite">
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:error} flash={@flash} />
|
||||
|
||||
<.flash
|
||||
id="client-error"
|
||||
kind={:error}
|
||||
title={gettext("We can't find the internet")}
|
||||
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
|
||||
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
id="server-error"
|
||||
kind={:error}
|
||||
title={gettext("Something went wrong!")}
|
||||
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
|
||||
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Provides dark vs light theme toggle based on themes defined in app.css.
|
||||
|
||||
See <head> in root.html.heex which applies the theme before page load.
|
||||
"""
|
||||
def theme_toggle(assigns) do
|
||||
~H"""
|
||||
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
|
||||
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="system"
|
||||
>
|
||||
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="light"
|
||||
>
|
||||
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="dark"
|
||||
>
|
||||
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
39
lib/beet_round_server_web/components/layouts/root.html.heex
Normal file
@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title default="BeetRound" suffix=" · Das grüne Zebra">
|
||||
{assigns[:page_title]}
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const setTheme = (theme) => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("phx:theme");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
};
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||
}
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
|
||||
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<ul class="menu menu-horizontal w-full relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
|
||||
<Layouts.theme_toggle />
|
||||
</ul>
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
56
lib/beet_round_server_web/controllers/admin_controller.ex
Normal file
@ -0,0 +1,56 @@
|
||||
defmodule BeetRoundServerWeb.AdminController do
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
alias BeetRoundServer.Admins
|
||||
alias BeetRoundServer.Admins.Admin
|
||||
|
||||
action_fallback BeetRoundServerWeb.FallbackController
|
||||
|
||||
def create(conn, %{"admin" => admin_params}) do
|
||||
with {:ok, %Admin{} = admin} <- Admins.register_admin(admin_params) do
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> render(:show, admin: admin)
|
||||
else
|
||||
{:error, _changeset} ->
|
||||
existingAdmin = Admins.get_admin_by_email(admin_params["email"])
|
||||
|
||||
if existingAdmin == nil do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> render(:error, %{error: "Admin could not be created!", admin: admin_params})
|
||||
else
|
||||
admin = %{
|
||||
mail: existingAdmin.email,
|
||||
id: existingAdmin.id
|
||||
}
|
||||
|
||||
conn
|
||||
|> put_status(:conflict)
|
||||
|> render(:error, %{error: "Admin already exists!", admin: admin})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
admin = Admins.get_admin!(id)
|
||||
render(conn, :show, admin: admin)
|
||||
end
|
||||
|
||||
def log_in(conn, %{"admin" => admin_params}) do
|
||||
case Admins.get_admin_by_email_and_password(admin_params["email"], admin_params["password"]) do
|
||||
nil ->
|
||||
IO.puts("Admin couldn't be found!")
|
||||
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> render(:error, %{error: "Invalid email or password!", admin: admin_params})
|
||||
|
||||
admin ->
|
||||
encoded_token = Admins.create_admin_api_token(admin)
|
||||
updated_admin = Map.put(admin, :token, encoded_token)
|
||||
|
||||
render(conn, :token, admin: updated_admin)
|
||||
end
|
||||
end
|
||||
end
|
||||
47
lib/beet_round_server_web/controllers/admin_json.ex
Normal file
@ -0,0 +1,47 @@
|
||||
defmodule BeetRoundServerWeb.AdminJSON do
|
||||
alias BeetRoundServer.Admins.Admin
|
||||
|
||||
@doc """
|
||||
Renders a list of admins.
|
||||
"""
|
||||
def index(%{admins: admins}) do
|
||||
%{data: for(admin <- admins, do: data(admin))}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a single admin.
|
||||
"""
|
||||
def show(%{admin: admin}) do
|
||||
%{
|
||||
data: data(admin)
|
||||
}
|
||||
end
|
||||
|
||||
def token(%{admin: admin}) do
|
||||
%{
|
||||
data: %{
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
token: admin.token
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def mail_status(%{status: status}) do
|
||||
%{data: status}
|
||||
end
|
||||
|
||||
def error(%{error: error, admin: admin}) do
|
||||
%{
|
||||
error: error,
|
||||
admin: admin
|
||||
}
|
||||
end
|
||||
|
||||
defp data(%Admin{} = admin) do
|
||||
%{
|
||||
id: admin.id,
|
||||
email: admin.email
|
||||
}
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,32 @@
|
||||
defmodule BeetRoundServerWeb.AdminRegistrationController do
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
alias BeetRoundServer.Admins
|
||||
alias BeetRoundServer.Admins.Admin
|
||||
|
||||
def new(conn, _params) do
|
||||
changeset = Admins.change_admin_email(%Admin{})
|
||||
render(conn, :new, changeset: changeset)
|
||||
end
|
||||
|
||||
def create(conn, %{"admin" => admin_params}) do
|
||||
case Admins.register_admin(admin_params) do
|
||||
{:ok, admin} ->
|
||||
{:ok, _} =
|
||||
Admins.deliver_login_instructions(
|
||||
admin,
|
||||
&url(~p"/admins/log-in/#{&1}")
|
||||
)
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"An email was sent to #{admin.email}, please access it to confirm your account."
|
||||
)
|
||||
|> redirect(to: ~p"/admins/log-in")
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render(conn, :new, changeset: changeset)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,5 @@
|
||||
defmodule BeetRoundServerWeb.AdminRegistrationHTML do
|
||||
use BeetRoundServerWeb, :html
|
||||
|
||||
embed_templates "admin_registration_html/*"
|
||||
end
|
||||
@ -0,0 +1,31 @@
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Register for an account
|
||||
<:subtitle>
|
||||
Already registered?
|
||||
<.link navigate={~p"/admins/log-in"} class="font-semibold text-brand hover:underline">
|
||||
Log in
|
||||
</.link>
|
||||
to your account now.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<.form :let={f} for={@changeset} action={~p"/admins/register"}>
|
||||
<.input
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
|
||||
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
|
||||
Create an account
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
@ -0,0 +1,88 @@
|
||||
defmodule BeetRoundServerWeb.AdminSessionController do
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
alias BeetRoundServer.Admins
|
||||
alias BeetRoundServerWeb.AdminAuth
|
||||
|
||||
def new(conn, _params) do
|
||||
email = get_in(conn.assigns, [:current_scope, Access.key(:admin), Access.key(:email)])
|
||||
form = Phoenix.Component.to_form(%{"email" => email}, as: "admin")
|
||||
|
||||
render(conn, :new, form: form)
|
||||
end
|
||||
|
||||
# magic link login
|
||||
def create(conn, %{"admin" => %{"token" => token} = admin_params} = params) do
|
||||
info =
|
||||
case params do
|
||||
%{"_action" => "confirmed"} -> "Admin confirmed successfully."
|
||||
_ -> "Welcome back!"
|
||||
end
|
||||
|
||||
case Admins.login_admin_by_magic_link(token) do
|
||||
{:ok, {admin, _expired_tokens}} ->
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> AdminAuth.log_in_admin(admin, admin_params)
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_flash(:error, "The link is invalid or it has expired.")
|
||||
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "admin"))
|
||||
end
|
||||
end
|
||||
|
||||
# email + password login
|
||||
def create(conn, %{"admin" => %{"email" => email, "password" => password} = admin_params}) do
|
||||
if admin = Admins.get_admin_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, "Welcome back!")
|
||||
|> AdminAuth.log_in_admin(admin, admin_params)
|
||||
else
|
||||
form = Phoenix.Component.to_form(admin_params, as: "admin")
|
||||
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||
conn
|
||||
|> put_flash(:error, "Invalid email or password")
|
||||
|> render(:new, form: form)
|
||||
end
|
||||
end
|
||||
|
||||
# magic link request
|
||||
def create(conn, %{"admin" => %{"email" => email}}) do
|
||||
if admin = Admins.get_admin_by_email(email) do
|
||||
Admins.deliver_login_instructions(
|
||||
admin,
|
||||
&url(~p"/admins/log-in/#{&1}")
|
||||
)
|
||||
end
|
||||
|
||||
info =
|
||||
"If your email is in our system, you will receive instructions for logging in shortly."
|
||||
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> redirect(to: ~p"/admins/log-in")
|
||||
end
|
||||
|
||||
def confirm(conn, %{"token" => token}) do
|
||||
if admin = Admins.get_admin_by_magic_link_token(token) do
|
||||
form = Phoenix.Component.to_form(%{"token" => token}, as: "admin")
|
||||
|
||||
conn
|
||||
|> assign(:admin, admin)
|
||||
|> assign(:form, form)
|
||||
|> render(:confirm)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Magic link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/admins/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> AdminAuth.log_out_admin()
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,9 @@
|
||||
defmodule BeetRoundServerWeb.AdminSessionHTML do
|
||||
use BeetRoundServerWeb, :html
|
||||
|
||||
embed_templates "admin_session_html/*"
|
||||
|
||||
defp local_mail_adapter? do
|
||||
Application.get_env(:beet_round_server, BeetRoundServer.Mailer)[:adapter] == Swoosh.Adapters.Local
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,59 @@
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>Welcome {@admin.email}</.header>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={!@admin.confirmed_at}
|
||||
for={@form}
|
||||
id="confirmation_form"
|
||||
action={~p"/admins/log-in?_action=confirmed"}
|
||||
phx-mounted={JS.focus_first()}
|
||||
>
|
||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||
<.button
|
||||
name={@form[:remember_me].name}
|
||||
value="true"
|
||||
phx-disable-with="Confirming..."
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
Confirm and stay logged in
|
||||
</.button>
|
||||
<.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
|
||||
Confirm and log in only this time
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<.form
|
||||
:if={@admin.confirmed_at}
|
||||
for={@form}
|
||||
id="login_form"
|
||||
action={~p"/admins/log-in"}
|
||||
phx-mounted={JS.focus_first()}
|
||||
>
|
||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||
<%= if @current_scope do %>
|
||||
<.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
|
||||
Log in
|
||||
</.button>
|
||||
<% else %>
|
||||
<.button
|
||||
name={@form[:remember_me].name}
|
||||
value="true"
|
||||
phx-disable-with="Logging in..."
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
Keep me logged in on this device
|
||||
</.button>
|
||||
<.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
|
||||
Log me in only this time
|
||||
</.button>
|
||||
<% end %>
|
||||
</.form>
|
||||
|
||||
<p :if={!@admin.confirmed_at} class="alert alert-outline mt-8">
|
||||
Tip: If you prefer passwords, you can enable them in the admin settings.
|
||||
</p>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
@ -0,0 +1,70 @@
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm space-y-4">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
<p>Log in</p>
|
||||
<:subtitle>
|
||||
<%= if @current_scope do %>
|
||||
You need to reauthenticate to perform sensitive actions on your account.
|
||||
<% else %>
|
||||
Don't have an account? <.link
|
||||
navigate={~p"/admins/register"}
|
||||
class="font-semibold text-brand hover:underline"
|
||||
phx-no-format
|
||||
>Sign up</.link> for an account now.
|
||||
<% end %>
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<div :if={local_mail_adapter?()} class="alert alert-info">
|
||||
<.icon name="hero-information-circle" class="size-6 shrink-0" />
|
||||
<div>
|
||||
<p>You are running the local mail adapter.</p>
|
||||
<p>
|
||||
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.form :let={f} for={@form} as={:admin} id="login_form_magic" action={~p"/admins/log-in"}>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<.button class="btn btn-primary w-full">
|
||||
Log in with email <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<div class="divider">or</div>
|
||||
|
||||
<.form :let={f} for={@form} as={:admin} id="login_form_password" action={~p"/admins/log-in"}>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={f[:password]}
|
||||
type="password"
|
||||
label="Password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
|
||||
Log in and stay logged in <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
<.button class="btn btn-primary btn-soft w-full mt-2">
|
||||
Log in only this time
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
@ -0,0 +1,77 @@
|
||||
defmodule BeetRoundServerWeb.AdminSettingsController do
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
alias BeetRoundServer.Admins
|
||||
alias BeetRoundServerWeb.AdminAuth
|
||||
|
||||
import BeetRoundServerWeb.AdminAuth, only: [require_sudo_mode: 2]
|
||||
|
||||
plug :require_sudo_mode
|
||||
plug :assign_email_and_password_changesets
|
||||
|
||||
def edit(conn, _params) do
|
||||
render(conn, :edit)
|
||||
end
|
||||
|
||||
def update(conn, %{"action" => "update_email"} = params) do
|
||||
%{"admin" => admin_params} = params
|
||||
admin = conn.assigns.current_scope.admin
|
||||
|
||||
case Admins.change_admin_email(admin, admin_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
Admins.deliver_admin_update_email_instructions(
|
||||
Ecto.Changeset.apply_action!(changeset, :insert),
|
||||
admin.email,
|
||||
&url(~p"/admins/settings/confirm-email/#{&1}")
|
||||
)
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"A link to confirm your email change has been sent to the new address."
|
||||
)
|
||||
|> redirect(to: ~p"/admins/settings")
|
||||
|
||||
changeset ->
|
||||
render(conn, :edit, email_changeset: %{changeset | action: :insert})
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"action" => "update_password"} = params) do
|
||||
%{"admin" => admin_params} = params
|
||||
admin = conn.assigns.current_scope.admin
|
||||
|
||||
case Admins.update_admin_password(admin, admin_params) do
|
||||
{:ok, {admin, _}} ->
|
||||
conn
|
||||
|> put_flash(:info, "Password updated successfully.")
|
||||
|> put_session(:admin_return_to, ~p"/admins/settings")
|
||||
|> AdminAuth.log_in_admin(admin)
|
||||
|
||||
{:error, changeset} ->
|
||||
render(conn, :edit, password_changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def confirm_email(conn, %{"token" => token}) do
|
||||
case Admins.update_admin_email(conn.assigns.current_scope.admin, token) do
|
||||
{:ok, _admin} ->
|
||||
conn
|
||||
|> put_flash(:info, "Email changed successfully.")
|
||||
|> redirect(to: ~p"/admins/settings")
|
||||
|
||||
{:error, _} ->
|
||||
conn
|
||||
|> put_flash(:error, "Email change link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/admins/settings")
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_email_and_password_changesets(conn, _opts) do
|
||||
admin = conn.assigns.current_scope.admin
|
||||
|
||||
conn
|
||||
|> assign(:email_changeset, Admins.change_admin_email(admin))
|
||||
|> assign(:password_changeset, Admins.change_admin_password(admin))
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,5 @@
|
||||
defmodule BeetRoundServerWeb.AdminSettingsHTML do
|
||||
use BeetRoundServerWeb, :html
|
||||
|
||||
embed_templates "admin_settings_html/*"
|
||||
end
|
||||
@ -0,0 +1,40 @@
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Account Settings
|
||||
<:subtitle>Manage your account email address and password settings</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<.form :let={f} for={@email_changeset} action={~p"/admins/settings"} id="update_email">
|
||||
<input type="hidden" name="action" value="update_email" />
|
||||
|
||||
<.input field={f[:email]} type="email" label="Email" autocomplete="email" required />
|
||||
|
||||
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
|
||||
</.form>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<.form :let={f} for={@password_changeset} action={~p"/admins/settings"} id="update_password">
|
||||
<input type="hidden" name="action" value="update_password" />
|
||||
|
||||
<.input
|
||||
field={f[:password]}
|
||||
type="password"
|
||||
label="New password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={f[:password_confirmation]}
|
||||
type="password"
|
||||
label="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<.button variant="primary" phx-disable-with="Changing...">
|
||||
Save Password
|
||||
</.button>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
59
lib/beet_round_server_web/controllers/bidding_controller.ex
Normal file
@ -0,0 +1,59 @@
|
||||
defmodule BeetRoundServerWeb.BiddingController do
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
alias BeetRoundServer.Biddings
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRoundFacade
|
||||
# alias BeetRoundServer.Biddings.Bidding
|
||||
|
||||
action_fallback BeetRoundServerWeb.FallbackController
|
||||
|
||||
def index(conn, _params) do
|
||||
biddings = Biddings.list_biddings()
|
||||
IO.puts("biddings:")
|
||||
IO.inspect(biddings)
|
||||
render(conn, :index, biddings: biddings)
|
||||
end
|
||||
|
||||
def biddings_of_round(conn, %{"round_number" => round_number}) do
|
||||
biddings = Biddings.biddings_of_round(round_number)
|
||||
render(conn, :index, biddings: biddings)
|
||||
end
|
||||
|
||||
def biddings_of_highest_round(conn, _params) do
|
||||
round = BiddingRoundFacade.get_highest_bidding_round()
|
||||
IO.puts("Highest round number:")
|
||||
IO.puts(round.round_number)
|
||||
biddings = Biddings.biddings_of_round(round.round_number)
|
||||
render(conn, :index, biddings: biddings)
|
||||
end
|
||||
|
||||
# def create(conn, %{"bidding" => bidding_params}) do
|
||||
# with {:ok, %Bidding{} = bidding} <- Biddings.create_bidding(bidding_params) do
|
||||
# conn
|
||||
# |> put_status(:created)
|
||||
# |> put_resp_header("location", ~p"/api/biddings/#{bidding}")
|
||||
# |> render(:show, bidding: bidding)
|
||||
# end
|
||||
# end
|
||||
|
||||
# def show(conn, %{"id" => id}) do
|
||||
# bidding = Biddings.get_bidding!(id)
|
||||
# render(conn, :show, bidding: bidding)
|
||||
# end
|
||||
|
||||
# def update(conn, %{"id" => id, "bidding" => bidding_params}) do
|
||||
# bidding = Biddings.get_bidding!(id)
|
||||
|
||||
# with {:ok, %Bidding{} = bidding} <- Biddings.update_bidding(bidding, bidding_params) do
|
||||
# render(conn, :show, bidding: bidding)
|
||||
# end
|
||||
# end
|
||||
|
||||
# def delete(conn, %{"id" => id}) do
|
||||
# bidding = Biddings.get_bidding!(id)
|
||||
|
||||
# with {:ok, %Bidding{}} <- Biddings.delete_bidding(bidding) do
|
||||
# send_resp(conn, :no_content, "")
|
||||
# end
|
||||
# end
|
||||
end
|
||||
28
lib/beet_round_server_web/controllers/bidding_json.ex
Normal file
@ -0,0 +1,28 @@
|
||||
defmodule BeetRoundServerWeb.BiddingJSON do
|
||||
alias BeetRoundServer.Biddings.Bidding
|
||||
|
||||
@doc """
|
||||
Renders a list of biddings.
|
||||
"""
|
||||
def index(%{biddings: biddings}) do
|
||||
%{data: for(bidding <- biddings, do: data(bidding))}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a single bidding.
|
||||
"""
|
||||
def show(%{bidding: bidding}) do
|
||||
%{data: data(bidding)}
|
||||
end
|
||||
|
||||
defp data(%Bidding{} = bidding) do
|
||||
%{
|
||||
user_id: bidding.user_id,
|
||||
id: bidding.id,
|
||||
bidding_round: bidding.bidding_round,
|
||||
amount: bidding.amount,
|
||||
depot_wish_one: bidding.depot_wish_one,
|
||||
depot_wish_two: bidding.depot_wish_two
|
||||
}
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,84 @@
|
||||
defmodule BeetRoundServerWeb.BiddingRoundController do
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
alias BeetRoundServer.BiddingRounds
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRound
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRoundFacade
|
||||
|
||||
action_fallback BeetRoundServerWeb.FallbackController
|
||||
|
||||
def get_highest(conn, _params) do
|
||||
BiddingRoundFacade.restart_if_necessary()
|
||||
|
||||
last_round = BiddingRoundFacade.get_highest_bidding_round()
|
||||
|
||||
conn
|
||||
|> render(:show, bidding_round: last_round)
|
||||
end
|
||||
|
||||
def start_new(conn, _params) do
|
||||
BiddingRoundFacade.start_new_round()
|
||||
|
||||
current_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> render(:show, bidding_round: current_round)
|
||||
end
|
||||
|
||||
def restart(conn, _params) do
|
||||
BiddingRoundFacade.restart_hightest_round()
|
||||
|
||||
current_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> render(:show, bidding_round: current_round)
|
||||
end
|
||||
|
||||
def stop(conn, _params) do
|
||||
BiddingRoundFacade.stop_current_round()
|
||||
|
||||
stopped_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
conn
|
||||
|> render(:show, bidding_round: stopped_round)
|
||||
end
|
||||
|
||||
def index(conn, _params) do
|
||||
bidding_rounds = BiddingRounds.list_bidding_rounds()
|
||||
render(conn, :index, bidding_rounds: bidding_rounds)
|
||||
end
|
||||
|
||||
# def create(conn, %{"bidding_round" => bidding_round_params}) do
|
||||
# with {:ok, %BiddingRound{} = bidding_round} <-
|
||||
# BiddingRounds.create_bidding_round(bidding_round_params) do
|
||||
# conn
|
||||
# |> put_status(:created)
|
||||
# |> put_resp_header("location", ~p"/api/bidding_rounds/#{bidding_round}")
|
||||
# |> render(:show, bidding_round: bidding_round)
|
||||
# end
|
||||
# end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
bidding_round = BiddingRounds.get_bidding_round!(id)
|
||||
render(conn, :show, bidding_round: bidding_round)
|
||||
end
|
||||
|
||||
def update(conn, %{"id" => id, "bidding_round" => bidding_round_params}) do
|
||||
bidding_round = BiddingRounds.get_bidding_round!(id)
|
||||
|
||||
with {:ok, %BiddingRound{} = bidding_round} <-
|
||||
BiddingRounds.update_bidding_round(bidding_round, bidding_round_params) do
|
||||
render(conn, :show, bidding_round: bidding_round)
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, %{"id" => id}) do
|
||||
bidding_round = BiddingRounds.get_bidding_round!(id)
|
||||
|
||||
with {:ok, %BiddingRound{}} <- BiddingRounds.delete_bidding_round(bidding_round) do
|
||||
send_resp(conn, :no_content, "")
|
||||
end
|
||||
end
|
||||
end
|
||||
25
lib/beet_round_server_web/controllers/bidding_round_json.ex
Normal file
@ -0,0 +1,25 @@
|
||||
defmodule BeetRoundServerWeb.BiddingRoundJSON do
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRound
|
||||
|
||||
@doc """
|
||||
Renders a list of bidding_rounds.
|
||||
"""
|
||||
def index(%{bidding_rounds: bidding_rounds}) do
|
||||
%{data: for(bidding_round <- bidding_rounds, do: data(bidding_round))}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a single bidding_round.
|
||||
"""
|
||||
def show(%{bidding_round: bidding_round}) do
|
||||
%{data: data(bidding_round)}
|
||||
end
|
||||
|
||||
defp data(%BiddingRound{} = bidding_round) do
|
||||
%{
|
||||
id: bidding_round.id,
|
||||
round_number: bidding_round.round_number,
|
||||
stopped: bidding_round.stopped
|
||||
}
|
||||
end
|
||||
end
|
||||
25
lib/beet_round_server_web/controllers/changeset_json.ex
Normal file
@ -0,0 +1,25 @@
|
||||
defmodule BeetRoundServerWeb.ChangesetJSON do
|
||||
@doc """
|
||||
Renders changeset errors.
|
||||
"""
|
||||
def error(%{changeset: changeset}) do
|
||||
# When encoded, the changeset returns its errors
|
||||
# as a JSON object. So we just pass it forward.
|
||||
%{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
|
||||
end
|
||||
|
||||
defp translate_error({msg, opts}) do
|
||||
# You can make use of gettext to translate error messages by
|
||||
# uncommenting and adjusting the following code:
|
||||
|
||||
# if count = opts[:count] do
|
||||
# Gettext.dngettext(BeetRoundServerWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
# else
|
||||
# Gettext.dgettext(BeetRoundServerWeb.Gettext, "errors", msg, opts)
|
||||
# end
|
||||
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,7 @@
|
||||
defmodule BeetRoundServerWeb.DefaultApiController do
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
def index(conn, _params) do
|
||||
text(conn, "The Beet Round API (#{Mix.env()}) is running.")
|
||||
end
|
||||
end
|
||||
24
lib/beet_round_server_web/controllers/error_html.ex
Normal file
@ -0,0 +1,24 @@
|
||||
defmodule BeetRoundServerWeb.ErrorHTML do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on HTML requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
use BeetRoundServerWeb, :html
|
||||
|
||||
# If you want to customize your error pages,
|
||||
# uncomment the embed_templates/1 call below
|
||||
# and add pages to the error directory:
|
||||
#
|
||||
# * lib/beet_round_server_web/controllers/error_html/404.html.heex
|
||||
# * lib/beet_round_server_web/controllers/error_html/500.html.heex
|
||||
#
|
||||
# embed_templates "error_html/*"
|
||||
|
||||
# The default is to render a plain text page based on
|
||||
# the template name. For example, "404.html" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
Phoenix.Controller.status_message_from_template(template)
|
||||
end
|
||||
end
|
||||
21
lib/beet_round_server_web/controllers/error_json.ex
Normal file
@ -0,0 +1,21 @@
|
||||
defmodule BeetRoundServerWeb.ErrorJSON do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
|
||||
# If you want to customize a particular status code,
|
||||
# you may add your own clauses, such as:
|
||||
#
|
||||
# def render("500.json", _assigns) do
|
||||
# %{errors: %{detail: "Internal Server Error"}}
|
||||
# end
|
||||
|
||||
# By default, Phoenix returns the status message from
|
||||
# the template name. For example, "404.json" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||
end
|
||||
end
|
||||
24
lib/beet_round_server_web/controllers/fallback_controller.ex
Normal file
@ -0,0 +1,24 @@
|
||||
defmodule BeetRoundServerWeb.FallbackController do
|
||||
@moduledoc """
|
||||
Translates controller action results into valid `Plug.Conn` responses.
|
||||
|
||||
See `Phoenix.Controller.action_fallback/1` for more details.
|
||||
"""
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
# This clause handles errors returned by Ecto's insert/update/delete.
|
||||
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> put_view(json: BeetRoundServerWeb.ChangesetJSON)
|
||||
|> render(:error, changeset: changeset)
|
||||
end
|
||||
|
||||
# This clause is an example of how to handle resources that cannot be found.
|
||||
def call(conn, {:error, :not_found}) do
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> put_view(html: BeetRoundServerWeb.ErrorHTML, json: BeetRoundServerWeb.ErrorJSON)
|
||||
|> render(:"404")
|
||||
end
|
||||
end
|
||||
7
lib/beet_round_server_web/controllers/page_controller.ex
Normal file
@ -0,0 +1,7 @@
|
||||
defmodule BeetRoundServerWeb.PageController do
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
def home(conn, _params) do
|
||||
render(conn, :home)
|
||||
end
|
||||
end
|
||||
10
lib/beet_round_server_web/controllers/page_html.ex
Normal file
@ -0,0 +1,10 @@
|
||||
defmodule BeetRoundServerWeb.PageHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by PageController.
|
||||
|
||||
See the `page_html` directory for all templates available.
|
||||
"""
|
||||
use BeetRoundServerWeb, :html
|
||||
|
||||
embed_templates "page_html/*"
|
||||
end
|
||||
@ -0,0 +1,55 @@
|
||||
<Layouts.flash_group flash={@flash} />
|
||||
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
|
||||
<div class="mx-auto max-w-xl lg:mx-0">
|
||||
<a href="/">
|
||||
<img src={~p"/images/BeetRound.png"} width="64" />
|
||||
</a>
|
||||
<div class="mt-10 flex justify-between items-center">
|
||||
<h1 class="flex items-center text-sm font-semibold leading-6">
|
||||
BeetRound
|
||||
<small class="badge badge-warning badge-sm ml-3">
|
||||
v{Application.spec(:beet_round_server, :vsn)}
|
||||
</small>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
|
||||
Digitales Bieten in einer SoLaWi.
|
||||
</p>
|
||||
<p class="mt-4 leading-7 text-base-content/70">
|
||||
Server zum Sammeln der digital abgegeben Gebote.
|
||||
</p>
|
||||
|
||||
<%= if true do %>
|
||||
<br />
|
||||
<a
|
||||
href={~p"/biddings"}
|
||||
class="group text-brand -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
Zu meinen Geboten
|
||||
</a>
|
||||
<% end %>
|
||||
|
||||
<div class="flex">
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
|
||||
<div>
|
||||
<a
|
||||
href="https://working-copy.org/projects/beetround/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
|
||||
</svg>
|
||||
by Working-Copy Collective
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
112
lib/beet_round_server_web/controllers/user_controller.ex
Normal file
@ -0,0 +1,112 @@
|
||||
defmodule BeetRoundServerWeb.UserController do
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
alias BeetRoundServer.Accounts
|
||||
alias BeetRoundServer.Accounts.User
|
||||
|
||||
action_fallback BeetRoundServerWeb.FallbackController
|
||||
|
||||
def index(conn, _params) do
|
||||
users = Accounts.list_users()
|
||||
render(conn, :index, users: users)
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => user_params}) do
|
||||
case Accounts.register_user(user_params) do
|
||||
{:ok, %User{} = user} ->
|
||||
send_created_response(conn, user)
|
||||
|
||||
{:error, changeset} ->
|
||||
with %User{} = user <- Accounts.get_user_by_email(user_params["email"]) do
|
||||
send_already_reported_response(conn, user)
|
||||
else
|
||||
_ ->
|
||||
send_not_acceptable(conn, changeset)
|
||||
end
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> put_resp_header("location", ~p"/api/users")
|
||||
|> render(:show, changeset: "Bad request")
|
||||
end
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
user = Accounts.get_user!(id)
|
||||
render(conn, :show, user: user)
|
||||
end
|
||||
|
||||
def invite(conn, %{"user" => user_params}) do
|
||||
case Accounts.get_user!(user_params["user_id"]) do
|
||||
nil ->
|
||||
IO.puts("User couldn't be found! Reason:")
|
||||
|
||||
user ->
|
||||
user_params = Map.put(user_params, "email", user.email)
|
||||
|
||||
email =
|
||||
BeetRoundServer.UserEmail.invite(%{
|
||||
name: user_params["name"],
|
||||
email: user_params["email"],
|
||||
access_url: user_params["access_url"]
|
||||
})
|
||||
|
||||
case BeetRoundServer.Mailer.deliver(email) do
|
||||
{:ok, data} ->
|
||||
IO.puts("Mail sent successfully.")
|
||||
IO.inspect(data)
|
||||
render(conn, :mail_status, %{status: "Mail sent successfully."})
|
||||
|
||||
{:error, error} ->
|
||||
IO.puts("Mail error:")
|
||||
IO.inspect(error)
|
||||
render(conn, :show, %User{})
|
||||
|
||||
# render(conn, :error, error: error, user: user_params)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# def update(conn, %{"id" => id, "user" => user_params}) do
|
||||
# user = Accounts.get_user!(id)
|
||||
|
||||
# with {:ok, %User{} = user} <- Accounts.update_user(user, user_params) do
|
||||
# render(conn, :show, user: user)
|
||||
# end
|
||||
# end
|
||||
|
||||
# def delete(conn, %{"id" => id}) do
|
||||
# user = Accounts.get_user!(id)
|
||||
|
||||
# with {:ok, %User{}} <- Accounts.delete_user(user) do
|
||||
# send_resp(conn, :no_content, "")
|
||||
# end
|
||||
# end
|
||||
|
||||
defp send_created_response(conn, %User{} = user) do
|
||||
encoded_token = Accounts.create_email_token(user)
|
||||
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> put_resp_header("location", ~p"/api/users/#{user}")
|
||||
|> render(:show, %{user: user, token: encoded_token})
|
||||
end
|
||||
|
||||
defp send_already_reported_response(conn, %User{} = user) do
|
||||
encoded_token = Accounts.create_email_token(user)
|
||||
IO.puts("encoded_token for user: " <> user.email)
|
||||
IO.inspect(encoded_token)
|
||||
|
||||
conn
|
||||
|> put_status(:already_reported)
|
||||
|> render(:show, %{user: user, token: encoded_token})
|
||||
end
|
||||
|
||||
defp send_not_acceptable(conn, changeset) do
|
||||
conn
|
||||
|> put_status(:not_acceptable)
|
||||
|> put_resp_header("location", ~p"/api/users")
|
||||
|> render(:show, changeset: changeset)
|
||||
end
|
||||
end
|
||||
53
lib/beet_round_server_web/controllers/user_json.ex
Normal file
@ -0,0 +1,53 @@
|
||||
defmodule BeetRoundServerWeb.UserJSON do
|
||||
alias BeetRoundServer.Accounts.User
|
||||
|
||||
@doc """
|
||||
Renders a list of users.
|
||||
"""
|
||||
def index(%{users: users}) do
|
||||
%{data: for(user <- users, do: data(user))}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a single user with token.
|
||||
"""
|
||||
def show(%{user: user, token: encoded_token}) do
|
||||
%{data: %{email: user.email, id: user.id, token: encoded_token}}
|
||||
end
|
||||
|
||||
def show(%{user: user}) do
|
||||
%{data: data(user)}
|
||||
end
|
||||
|
||||
def show(%{changeset: changeset}) do
|
||||
# When encoded, the changeset returns its errors
|
||||
# as a JSON object. So we just pass it forward.
|
||||
%{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
|
||||
end
|
||||
|
||||
def mail_status(%{status: status}) do
|
||||
%{data: status}
|
||||
end
|
||||
|
||||
defp data(%User{} = user) do
|
||||
%{
|
||||
id: user.id,
|
||||
email: user.email
|
||||
}
|
||||
end
|
||||
|
||||
defp translate_error({msg, opts}) do
|
||||
# You can make use of gettext to translate error messages by
|
||||
# uncommenting and adjusting the following code:
|
||||
|
||||
# if count = opts[:count] do
|
||||
# Gettext.dngettext(BeetRoundServerWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
# else
|
||||
# Gettext.dgettext(BeetRoundServerWeb.Gettext, "errors", msg, opts)
|
||||
# end
|
||||
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,84 @@
|
||||
defmodule BeetRoundServerWeb.UserSessionController do
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
alias BeetRoundServer.Accounts
|
||||
alias BeetRoundServerWeb.UserAuth
|
||||
|
||||
def create(conn, %{"_action" => "confirmed"} = params) do
|
||||
create(conn, params, "User confirmed successfully.")
|
||||
end
|
||||
|
||||
def create(conn, params) do
|
||||
create(conn, params, "Welcome back!")
|
||||
end
|
||||
|
||||
def login(conn, %{"token" => token}) do
|
||||
IO.puts("Login via token:")
|
||||
IO.inspect(token)
|
||||
|
||||
UserAuth.log_out_user_without_redirect(conn)
|
||||
|
||||
if user = Accounts.get_user_by_email_token(token) do
|
||||
conn
|
||||
|> put_flash(:info, "Login successful!")
|
||||
|> UserAuth.log_in_without_creating_cookie(user)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Invalid token")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
|
||||
# magic link login
|
||||
defp create(conn, %{"user" => %{"token" => token} = user_params}, info) do
|
||||
case Accounts.login_user_by_magic_link(token) do
|
||||
{:ok, {user, tokens_to_disconnect}} ->
|
||||
UserAuth.disconnect_sessions(tokens_to_disconnect)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_flash(:error, "The link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
# email + password login
|
||||
defp create(conn, %{"user" => user_params}, info) do
|
||||
%{"email" => email, "password" => password} = user_params
|
||||
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
else
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||
conn
|
||||
|> put_flash(:error, "Invalid email or password")
|
||||
|> put_flash(:email, String.slice(email, 0, 160))
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
def update_password(conn, %{"user" => user_params} = params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
true = Accounts.sudo_mode?(user)
|
||||
{:ok, {_user, expired_tokens}} = Accounts.update_user_password(user, user_params)
|
||||
|
||||
# disconnect all existing LiveViews with old sessions
|
||||
UserAuth.disconnect_sessions(expired_tokens)
|
||||
|
||||
conn
|
||||
|> put_session(:user_return_to, ~p"/users/settings")
|
||||
|> create(params, "Password updated successfully!")
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> UserAuth.log_out_user()
|
||||
end
|
||||
end
|
||||
54
lib/beet_round_server_web/endpoint.ex
Normal file
@ -0,0 +1,54 @@
|
||||
defmodule BeetRoundServerWeb.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :beet_round_server
|
||||
|
||||
# The session will be stored in the cookie and signed,
|
||||
# this means its contents can be read but not tampered with.
|
||||
# Set :encryption_salt if you would also like to encrypt it.
|
||||
@session_options [
|
||||
store: :cookie,
|
||||
key: "_beet_round_server_key",
|
||||
signing_salt: "/UzlZhzi",
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket,
|
||||
websocket: [connect_info: [session: @session_options]],
|
||||
longpoll: [connect_info: [session: @session_options]]
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
#
|
||||
# When code reloading is disabled (e.g., in production),
|
||||
# the `gzip` option is enabled to serve compressed
|
||||
# static files generated by running `phx.digest`.
|
||||
plug Plug.Static,
|
||||
at: "/",
|
||||
from: :beet_round_server,
|
||||
gzip: not code_reloading?,
|
||||
only: BeetRoundServerWeb.static_paths()
|
||||
|
||||
# Code reloading can be explicitly enabled under the
|
||||
# :code_reloader configuration of your endpoint.
|
||||
if code_reloading? do
|
||||
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||
plug Phoenix.LiveReloader
|
||||
plug Phoenix.CodeReloader
|
||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :beet_round_server
|
||||
end
|
||||
|
||||
plug Phoenix.LiveDashboard.RequestLogger,
|
||||
param_key: "request_logger",
|
||||
cookie_key: "request_logger"
|
||||
|
||||
plug Plug.RequestId
|
||||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||
|
||||
plug Plug.Parsers,
|
||||
parsers: [:urlencoded, :multipart, :json],
|
||||
pass: ["*/*"],
|
||||
json_decoder: Phoenix.json_library()
|
||||
|
||||
plug Plug.MethodOverride
|
||||
plug Plug.Head
|
||||
plug Plug.Session, @session_options
|
||||
plug BeetRoundServerWeb.Router
|
||||
end
|
||||
25
lib/beet_round_server_web/gettext.ex
Normal file
@ -0,0 +1,25 @@
|
||||
defmodule BeetRoundServerWeb.Gettext do
|
||||
@moduledoc """
|
||||
A module providing Internationalization with a gettext-based API.
|
||||
|
||||
By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
|
||||
that you can use in your application. To use this Gettext backend module,
|
||||
call `use Gettext` and pass it as an option:
|
||||
|
||||
use Gettext, backend: BeetRoundServerWeb.Gettext
|
||||
|
||||
# Simple translation
|
||||
gettext("Here is the string to translate")
|
||||
|
||||
# Plural translation
|
||||
ngettext("Here is the string to translate",
|
||||
"Here are the strings to translate",
|
||||
3)
|
||||
|
||||
# Domain-based translation
|
||||
dgettext("errors", "Here is the error message to translate")
|
||||
|
||||
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||
"""
|
||||
use Gettext.Backend, otp_app: :beet_round_server
|
||||
end
|
||||
162
lib/beet_round_server_web/live/bidding_live/form.ex
Normal file
@ -0,0 +1,162 @@
|
||||
defmodule BeetRoundServerWeb.BiddingLive.Form do
|
||||
use BeetRoundServerWeb, :live_view
|
||||
|
||||
alias BeetRoundServer.Biddings
|
||||
alias BeetRoundServer.Biddings.Bidding
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRoundFacade
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>Bitte gib hier den Betrag ein, den Du monatlich bezahlen willst.</:subtitle>
|
||||
</.header>
|
||||
<%= if @bidding.bidding_round == 0 do %>
|
||||
<p><b>Keine Bietrunde aktiv.</b></p>
|
||||
<footer>
|
||||
<.button navigate={return_path(@current_scope, @return_to, @bidding)}>Zurück</.button>
|
||||
</footer>
|
||||
<% else %>
|
||||
<.form for={@form} id="bidding-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:amount]} type="number" label="Betrag" />
|
||||
<.input
|
||||
field={@form[:depot_wish_one]}
|
||||
type="select"
|
||||
label="Depot Wunsch 1"
|
||||
options={[
|
||||
{"", ""},
|
||||
{"KlimaWerkStadt (1)", "KlimaWerkStadt"},
|
||||
{"Puramila (2)", "Puramila"},
|
||||
{"Eine Welt Aktion (3)", "Eine Welt Aktion"},
|
||||
{"Hof Buntentor (4)", "Hof Buntentor"},
|
||||
{"Mädchen-Kulturhaus (5)", "Mädchen-Kulturhaus"},
|
||||
{"neues Depot im Viertel (6)", "neues Depot im Viertel"},
|
||||
{"Creative Hub (7)", "Creative Hub"},
|
||||
{"Klimazone (8)", "Klimazone"},
|
||||
{"Garage Walle (9)", "Garage Walle"},
|
||||
{"Hof Riede (A)", "Hof Riede"},
|
||||
{"Thedinghausen (B)", "Thedinghausen"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:depot_wish_two]}
|
||||
type="select"
|
||||
label="Depot Wunsch 2"
|
||||
options={[
|
||||
{"", ""},
|
||||
{"KlimaWerkStadt (1)", "KlimaWerkStadt"},
|
||||
{"Puramila (2)", "Puramila"},
|
||||
{"Eine Welt Aktion (3)", "Eine Welt Aktion"},
|
||||
{"Hof Buntentor (4)", "Hof Buntentor"},
|
||||
{"Mädchen-Kulturhaus (5)", "Mädchen-Kulturhaus"},
|
||||
{"neues Depot im Viertel (6)", "neues Depot im Viertel"},
|
||||
{"Creative Hub (7)", "Creative Hub"},
|
||||
{"Klimazone (8)", "Klimazone"},
|
||||
{"Garage Walle (9)", "Garage Walle"},
|
||||
{"Hof Riede (A)", "Hof Riede"},
|
||||
{"Thedinghausen (B)", "Thedinghausen"}
|
||||
]}
|
||||
/>
|
||||
<.input field={@form[:bidding_round]} type="number" readonly hidden />
|
||||
<footer>
|
||||
<.button phx-disable-with="Bearbeitung..." variant="primary">Gebot abgeben</.button>
|
||||
<.button navigate={return_path(@current_scope, @return_to, @bidding)}>Abbrechen</.button>
|
||||
</footer>
|
||||
</.form>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
# current_round = BiddingRoundFacade.get_current_round()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
# |> assign(bidding_round: current_round)
|
||||
|> apply_action(socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp return_to("show"), do: "show"
|
||||
defp return_to(_), do: "index"
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
bidding = Biddings.get_bidding!(socket.assigns.current_scope, id)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Gebot bearbeiten")
|
||||
|> assign(:bidding, bidding)
|
||||
|> assign(:form, to_form(Biddings.change_bidding(socket.assigns.current_scope, bidding)))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
current_round = BiddingRoundFacade.get_current_round()
|
||||
|
||||
bidding = %Bidding{
|
||||
user_id: socket.assigns.current_scope.user.id,
|
||||
bidding_round: current_round
|
||||
}
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Neues Gebot")
|
||||
|> assign(:bidding, bidding)
|
||||
|> assign(:form, to_form(Biddings.change_bidding(socket.assigns.current_scope, bidding)))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"bidding" => bidding_params}, socket) do
|
||||
changeset =
|
||||
Biddings.change_bidding(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.bidding,
|
||||
bidding_params
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"bidding" => bidding_params}, socket) do
|
||||
save_bidding(socket, socket.assigns.live_action, bidding_params)
|
||||
end
|
||||
|
||||
defp save_bidding(socket, :edit, bidding_params) do
|
||||
case Biddings.update_bidding(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.bidding,
|
||||
bidding_params
|
||||
) do
|
||||
{:ok, bidding} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Gebot erfolgreich bearbeitet")
|
||||
|> push_navigate(
|
||||
to: return_path(socket.assigns.current_scope, socket.assigns.return_to, bidding)
|
||||
)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_bidding(socket, :new, bidding_params) do
|
||||
case Biddings.create_bidding(socket.assigns.current_scope, bidding_params) do
|
||||
{:ok, bidding} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Gebot erfolgreich abgegeben")
|
||||
|> push_navigate(
|
||||
to: return_path(socket.assigns.current_scope, socket.assigns.return_to, bidding)
|
||||
)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp return_path(_scope, "index", _bidding), do: ~p"/biddings"
|
||||
defp return_path(_scope, "show", bidding), do: ~p"/biddings/#{bidding}"
|
||||
end
|
||||
78
lib/beet_round_server_web/live/bidding_live/index.ex
Normal file
@ -0,0 +1,78 @@
|
||||
defmodule BeetRoundServerWeb.BiddingLive.Index do
|
||||
use BeetRoundServerWeb, :live_view
|
||||
|
||||
alias BeetRoundServer.Biddings
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRoundFacade
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.header>
|
||||
{@current_scope.user.email}
|
||||
</.header>
|
||||
|
||||
<%= if @bidding_round == 0 do %>
|
||||
<p>Keine Bietrunde aktiv. Aktuell kein Bieten möglich!</p>
|
||||
<% else %>
|
||||
<p>Aktive Bietrunde: {@bidding_round} - Es kann geboten werden!</p>
|
||||
<div align="right">
|
||||
<.button variant="primary" navigate={~p"/biddings/new"}>
|
||||
<.icon name="hero-plus" /> Neues Gebot
|
||||
</.button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<br />
|
||||
|
||||
<%= if @current_bidding do %>
|
||||
<p><b>Aktuelles Gebot:</b></p>
|
||||
<.list>
|
||||
<:item title="Bietrunde">{@current_bidding.bidding_round}</:item>
|
||||
<:item title="monatl. Betrag">{@current_bidding.amount} €</:item>
|
||||
<:item title="Depot Wunsch 1">{@current_bidding.depot_wish_one}</:item>
|
||||
<:item title="Depot Wunsch 2">{@current_bidding.depot_wish_two}</:item>
|
||||
</.list>
|
||||
<% else %>
|
||||
<p>Noch kein Gebot abgegeben</p>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Biddings.subscribe_biddings(socket.assigns.current_scope)
|
||||
end
|
||||
|
||||
current_round = BiddingRoundFacade.get_current_round()
|
||||
current_bidding = Biddings.get_most_recent_bidding(socket.assigns.current_scope)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Aktuelles Gebot")
|
||||
|> assign(bidding_round: current_round)
|
||||
|> assign(current_bidding: current_bidding)
|
||||
|> stream(:biddings, list_biddings(socket.assigns.current_scope))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
bidding = Biddings.get_bidding!(socket.assigns.current_scope, id)
|
||||
{:ok, _} = Biddings.delete_bidding(socket.assigns.current_scope, bidding)
|
||||
|
||||
{:noreply, stream_delete(socket, :biddings, bidding)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({type, %BeetRoundServer.Biddings.Bidding{}}, socket)
|
||||
when type in [:created, :updated, :deleted] do
|
||||
{:noreply,
|
||||
stream(socket, :biddings, list_biddings(socket.assigns.current_scope), reset: true)}
|
||||
end
|
||||
|
||||
defp list_biddings(current_scope) do
|
||||
Biddings.list_biddings(current_scope)
|
||||
end
|
||||
end
|
||||
67
lib/beet_round_server_web/live/bidding_live/show.ex
Normal file
@ -0,0 +1,67 @@
|
||||
defmodule BeetRoundServerWeb.BiddingLive.Show do
|
||||
use BeetRoundServerWeb, :live_view
|
||||
|
||||
alias BeetRoundServer.Biddings
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.header>
|
||||
Bidding {@bidding.id}
|
||||
<:subtitle>This is a bidding record from your database.</:subtitle>
|
||||
<:actions>
|
||||
<.button navigate={~p"/biddings"}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
</.button>
|
||||
<.button variant="primary" navigate={~p"/biddings/#{@bidding}/edit?return_to=show"}>
|
||||
<.icon name="hero-pencil-square" /> Edit bidding
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Bidding round">{@bidding.bidding_round}</:item>
|
||||
<:item title="Amount">{@bidding.amount}</:item>
|
||||
<:item title="Depot wish one">{@bidding.depot_wish_one}</:item>
|
||||
<:item title="Depot wish two">{@bidding.depot_wish_two}</:item>
|
||||
</.list>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Biddings.subscribe_biddings(socket.assigns.current_scope)
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Show Bidding")
|
||||
|> assign(:bidding, Biddings.get_bidding!(socket.assigns.current_scope, id))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{:updated, %BeetRoundServer.Biddings.Bidding{id: id} = bidding},
|
||||
%{assigns: %{bidding: %{id: id}}} = socket
|
||||
) do
|
||||
{:noreply, assign(socket, :bidding, bidding)}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{:deleted, %BeetRoundServer.Biddings.Bidding{id: id}},
|
||||
%{assigns: %{bidding: %{id: id}}} = socket
|
||||
) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "The current bidding was deleted.")
|
||||
|> push_navigate(to: ~p"/biddings")}
|
||||
end
|
||||
|
||||
def handle_info({type, %BeetRoundServer.Biddings.Bidding{}}, socket)
|
||||
when type in [:created, :updated, :deleted] do
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
94
lib/beet_round_server_web/live/user_live/confirmation.ex
Normal file
@ -0,0 +1,94 @@
|
||||
defmodule BeetRoundServerWeb.UserLive.Confirmation do
|
||||
use BeetRoundServerWeb, :live_view
|
||||
|
||||
alias BeetRoundServer.Accounts
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>Welcome {@user.email}</.header>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={!@user.confirmed_at}
|
||||
for={@form}
|
||||
id="confirmation_form"
|
||||
phx-mounted={JS.focus_first()}
|
||||
phx-submit="submit"
|
||||
action={~p"/users/log-in?_action=confirmed"}
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||
<.button
|
||||
name={@form[:remember_me].name}
|
||||
value="true"
|
||||
phx-disable-with="Confirming..."
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
Confirm and stay logged in
|
||||
</.button>
|
||||
<.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
|
||||
Confirm and log in only this time
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<.form
|
||||
:if={@user.confirmed_at}
|
||||
for={@form}
|
||||
id="login_form"
|
||||
phx-submit="submit"
|
||||
phx-mounted={JS.focus_first()}
|
||||
action={~p"/users/log-in"}
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||
<%= if @current_scope do %>
|
||||
<.button phx-disable-with="Logging in..." class="btn btn-primary w-full">
|
||||
Log in
|
||||
</.button>
|
||||
<% else %>
|
||||
<.button
|
||||
name={@form[:remember_me].name}
|
||||
value="true"
|
||||
phx-disable-with="Logging in..."
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
Keep me logged in on this device
|
||||
</.button>
|
||||
<.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
|
||||
Log me in only this time
|
||||
</.button>
|
||||
<% end %>
|
||||
</.form>
|
||||
|
||||
<p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
|
||||
Tip: If you prefer passwords, you can enable them in the user settings.
|
||||
</p>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(%{"token" => token}, _session, socket) do
|
||||
if user = Accounts.get_user_by_magic_link_token(token) do
|
||||
form = to_form(%{"token" => token}, as: "user")
|
||||
|
||||
{:ok, assign(socket, user: user, form: form, trigger_submit: false),
|
||||
temporary_assigns: [form: nil]}
|
||||
else
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Magic link is invalid or it has expired.")
|
||||
|> push_navigate(to: ~p"/users/log-in")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit", %{"user" => params}, socket) do
|
||||
{:noreply, assign(socket, form: to_form(params, as: "user"), trigger_submit: true)}
|
||||
end
|
||||
end
|
||||
61
lib/beet_round_server_web/live/user_live/login.ex
Normal file
@ -0,0 +1,61 @@
|
||||
defmodule BeetRoundServerWeb.UserLive.Login do
|
||||
use BeetRoundServerWeb, :live_view
|
||||
|
||||
alias BeetRoundServer.Accounts
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm space-y-4">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
<p>Log in</p>
|
||||
<:subtitle>
|
||||
Bitte nutze deinen persönlichen Link der dir per Mail zugesendet wurde um dich anzumelden.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
email =
|
||||
Phoenix.Flash.get(socket.assigns.flash, :email) ||
|
||||
get_in(socket.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
|
||||
|
||||
form = to_form(%{"email" => email}, as: "user")
|
||||
|
||||
{:ok, assign(socket, form: form, trigger_submit: false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit_password", _params, socket) do
|
||||
{:noreply, assign(socket, :trigger_submit, true)}
|
||||
end
|
||||
|
||||
def handle_event("submit_magic", %{"user" => %{"email" => email}}, socket) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&url(~p"/users/log-in/#{&1}")
|
||||
)
|
||||
end
|
||||
|
||||
info =
|
||||
"If your email is in our system, you will receive instructions for logging in shortly."
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, info)
|
||||
|> push_navigate(to: ~p"/users/log-in")}
|
||||
end
|
||||
|
||||
defp local_mail_adapter? do
|
||||
Application.get_env(:beet_round_server, BeetRoundServer.Mailer)[:adapter] ==
|
||||
Swoosh.Adapters.Local
|
||||
end
|
||||
end
|
||||
88
lib/beet_round_server_web/live/user_live/registration.ex
Normal file
@ -0,0 +1,88 @@
|
||||
defmodule BeetRoundServerWeb.UserLive.Registration do
|
||||
use BeetRoundServerWeb, :live_view
|
||||
|
||||
alias BeetRoundServer.Accounts
|
||||
alias BeetRoundServer.Accounts.User
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Register for an account
|
||||
<:subtitle>
|
||||
Already registered?
|
||||
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
|
||||
Log in
|
||||
</.link>
|
||||
to your account now.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<.form for={@form} id="registration_form" phx-submit="save" phx-change="validate">
|
||||
<.input
|
||||
field={@form[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="username"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
|
||||
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
|
||||
Create an account
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, %{assigns: %{current_scope: %{user: user}}} = socket)
|
||||
when not is_nil(user) do
|
||||
{:ok, redirect(socket, to: BeetRoundServerWeb.UserAuth.signed_in_path(socket))}
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
changeset = Accounts.change_user_email(%User{}, %{}, validate_unique: false)
|
||||
|
||||
{:ok, assign_form(socket, changeset), temporary_assigns: [form: nil]}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
case Accounts.register_user(user_params) do
|
||||
{:ok, user} ->
|
||||
{:ok, _} =
|
||||
Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&url(~p"/users/log-in/#{&1}")
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
"An email was sent to #{user.email}, please access it to confirm your account."
|
||||
)
|
||||
|> push_navigate(to: ~p"/users/log-in")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
changeset = Accounts.change_user_email(%User{}, user_params, validate_unique: false)
|
||||
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
|
||||
end
|
||||
|
||||
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||
form = to_form(changeset, as: "user")
|
||||
assign(socket, form: form)
|
||||
end
|
||||
end
|
||||
157
lib/beet_round_server_web/live/user_live/settings.ex
Normal file
@ -0,0 +1,157 @@
|
||||
defmodule BeetRoundServerWeb.UserLive.Settings do
|
||||
use BeetRoundServerWeb, :live_view
|
||||
|
||||
on_mount {BeetRoundServerWeb.UserAuth, :require_sudo_mode}
|
||||
|
||||
alias BeetRoundServer.Accounts
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Account Settings
|
||||
<:subtitle>Manage your account email address and password settings</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<.form for={@email_form} id="email_form" phx-submit="update_email" phx-change="validate_email">
|
||||
<.input
|
||||
field={@email_form[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
|
||||
</.form>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<.form
|
||||
for={@password_form}
|
||||
id="password_form"
|
||||
action={~p"/users/update-password"}
|
||||
method="post"
|
||||
phx-change="validate_password"
|
||||
phx-submit="update_password"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<input
|
||||
name={@password_form[:email].name}
|
||||
type="hidden"
|
||||
id="hidden_user_email"
|
||||
autocomplete="username"
|
||||
value={@current_email}
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password]}
|
||||
type="password"
|
||||
label="New password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password_confirmation]}
|
||||
type="password"
|
||||
label="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<.button variant="primary" phx-disable-with="Saving...">
|
||||
Save Password
|
||||
</.button>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(%{"token" => token}, _session, socket) do
|
||||
socket =
|
||||
case Accounts.update_user_email(socket.assigns.current_scope.user, token) do
|
||||
{:ok, _user} ->
|
||||
put_flash(socket, :info, "Email changed successfully.")
|
||||
|
||||
{:error, _} ->
|
||||
put_flash(socket, :error, "Email change link is invalid or it has expired.")
|
||||
end
|
||||
|
||||
{:ok, push_navigate(socket, to: ~p"/users/settings")}
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
|
||||
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:current_email, user.email)
|
||||
|> assign(:email_form, to_form(email_changeset))
|
||||
|> assign(:password_form, to_form(password_changeset))
|
||||
|> assign(:trigger_submit, false)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_email", params, socket) do
|
||||
%{"user" => user_params} = params
|
||||
|
||||
email_form =
|
||||
socket.assigns.current_scope.user
|
||||
|> Accounts.change_user_email(user_params, validate_unique: false)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, email_form: email_form)}
|
||||
end
|
||||
|
||||
def handle_event("update_email", params, socket) do
|
||||
%{"user" => user_params} = params
|
||||
user = socket.assigns.current_scope.user
|
||||
true = Accounts.sudo_mode?(user)
|
||||
|
||||
case Accounts.change_user_email(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
Accounts.deliver_user_update_email_instructions(
|
||||
Ecto.Changeset.apply_action!(changeset, :insert),
|
||||
user.email,
|
||||
&url(~p"/users/settings/confirm-email/#{&1}")
|
||||
)
|
||||
|
||||
info = "A link to confirm your email change has been sent to the new address."
|
||||
{:noreply, socket |> put_flash(:info, info)}
|
||||
|
||||
changeset ->
|
||||
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_password", params, socket) do
|
||||
%{"user" => user_params} = params
|
||||
|
||||
password_form =
|
||||
socket.assigns.current_scope.user
|
||||
|> Accounts.change_user_password(user_params, hash_password: false)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, password_form: password_form)}
|
||||
end
|
||||
|
||||
def handle_event("update_password", params, socket) do
|
||||
%{"user" => user_params} = params
|
||||
user = socket.assigns.current_scope.user
|
||||
true = Accounts.sudo_mode?(user)
|
||||
|
||||
case Accounts.change_user_password(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
|
||||
|
||||
changeset ->
|
||||
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
|
||||
end
|
||||
end
|
||||
end
|
||||
111
lib/beet_round_server_web/router.ex
Normal file
@ -0,0 +1,111 @@
|
||||
defmodule BeetRoundServerWeb.Router do
|
||||
use BeetRoundServerWeb, :router
|
||||
|
||||
import BeetRoundServerWeb.AdminAuth
|
||||
|
||||
import BeetRoundServerWeb.UserAuth
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, html: {BeetRoundServerWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :fetch_current_scope_for_admin
|
||||
plug :fetch_current_scope_for_user
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
|
||||
pipeline :admin do
|
||||
plug :fetch_api_admin
|
||||
end
|
||||
|
||||
scope "/", BeetRoundServerWeb do
|
||||
pipe_through :browser
|
||||
|
||||
get "/", PageController, :home
|
||||
end
|
||||
|
||||
### API ###
|
||||
scope "/api", BeetRoundServerWeb do
|
||||
pipe_through :api
|
||||
|
||||
post "/log_in", AdminController, :log_in
|
||||
# post "/admin_create", AdminController, :create
|
||||
end
|
||||
|
||||
### protected API ###
|
||||
scope "/api", BeetRoundServerWeb do
|
||||
pipe_through [:api, :admin]
|
||||
|
||||
get "/", DefaultApiController, :index
|
||||
|
||||
get "/bidding_rounds/get_current", BiddingRoundController, :get_highest
|
||||
get "/bidding_rounds/start_new", BiddingRoundController, :start_new
|
||||
get "/bidding_rounds/restart", BiddingRoundController, :restart
|
||||
get "/bidding_rounds/stop", BiddingRoundController, :stop
|
||||
|
||||
get "/biddings_of_round/:round_number", BiddingController, :biddings_of_round
|
||||
get "/biddings_of_highest_round", BiddingController, :biddings_of_highest_round
|
||||
|
||||
post "/invite", UserController, :invite
|
||||
|
||||
resources "/users", UserController, except: [:new, :edit]
|
||||
end
|
||||
|
||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||
if Application.compile_env(:beet_round_server, :dev_routes) do
|
||||
# If you want to use the LiveDashboard in production, you should put
|
||||
# it behind authentication and allow only admins to access it.
|
||||
# If your application does not have an admins-only section yet,
|
||||
# you can use Plug.BasicAuth to set up some basic authentication
|
||||
# as long as you are also using SSL (which you should anyway).
|
||||
import Phoenix.LiveDashboard.Router
|
||||
|
||||
scope "/dev" do
|
||||
pipe_through :browser
|
||||
|
||||
live_dashboard "/dashboard", metrics: BeetRoundServerWeb.Telemetry
|
||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||
end
|
||||
end
|
||||
|
||||
## Authentication routes
|
||||
|
||||
scope "/", BeetRoundServerWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live_session :require_authenticated_user,
|
||||
on_mount: [{BeetRoundServerWeb.UserAuth, :require_authenticated}] do
|
||||
live "/users/settings", UserLive.Settings, :edit
|
||||
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
|
||||
|
||||
live "/biddings", BiddingLive.Index, :index
|
||||
live "/biddings/new", BiddingLive.Form, :new
|
||||
live "/biddings/:id", BiddingLive.Show, :show
|
||||
live "/biddings/:id/edit", BiddingLive.Form, :edit
|
||||
end
|
||||
|
||||
post "/users/update-password", UserSessionController, :update_password
|
||||
end
|
||||
|
||||
scope "/", BeetRoundServerWeb do
|
||||
pipe_through [:browser]
|
||||
|
||||
live_session :current_user,
|
||||
on_mount: [{BeetRoundServerWeb.UserAuth, :mount_current_scope}] do
|
||||
live "/users/register", UserLive.Registration, :new
|
||||
live "/users/log-in", UserLive.Login, :new
|
||||
live "/users/log-in/:token", UserLive.Confirmation, :new
|
||||
end
|
||||
|
||||
post "/users/log-in", UserSessionController, :create
|
||||
delete "/users/log-out", UserSessionController, :delete
|
||||
|
||||
get "/log_in/:token", UserSessionController, :login
|
||||
end
|
||||
end
|
||||
93
lib/beet_round_server_web/telemetry.ex
Normal file
@ -0,0 +1,93 @@
|
||||
defmodule BeetRoundServerWeb.Telemetry do
|
||||
use Supervisor
|
||||
import Telemetry.Metrics
|
||||
|
||||
def start_link(arg) do
|
||||
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_arg) do
|
||||
children = [
|
||||
# Telemetry poller will execute the given period measurements
|
||||
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||
# Add reporters as children of your supervision tree.
|
||||
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def metrics do
|
||||
[
|
||||
# Phoenix Metrics
|
||||
summary("phoenix.endpoint.start.system_time",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.endpoint.stop.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.start.system_time",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.exception.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.stop.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.socket_connected.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
sum("phoenix.socket_drain.count"),
|
||||
summary("phoenix.channel_joined.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.channel_handled_in.duration",
|
||||
tags: [:event],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
|
||||
# Database Metrics
|
||||
summary("beet_round_server.repo.query.total_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The sum of the other measurements"
|
||||
),
|
||||
summary("beet_round_server.repo.query.decode_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent decoding the data received from the database"
|
||||
),
|
||||
summary("beet_round_server.repo.query.query_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent executing the query"
|
||||
),
|
||||
summary("beet_round_server.repo.query.queue_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent waiting for a database connection"
|
||||
),
|
||||
summary("beet_round_server.repo.query.idle_time",
|
||||
unit: {:native, :millisecond},
|
||||
description:
|
||||
"The time the connection spent waiting before being checked out for the query"
|
||||
),
|
||||
|
||||
# VM Metrics
|
||||
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||
summary("vm.total_run_queue_lengths.total"),
|
||||
summary("vm.total_run_queue_lengths.cpu"),
|
||||
summary("vm.total_run_queue_lengths.io")
|
||||
]
|
||||
end
|
||||
|
||||
defp periodic_measurements do
|
||||
[
|
||||
# A module, function and arguments to be invoked periodically.
|
||||
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||
# {BeetRoundServerWeb, :count_users, []}
|
||||
]
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,23 @@
|
||||
<h1>Bietrunde 26/27 - Das Grüne Zebra</h1>
|
||||
<h2>Digitales Bieten</h2>
|
||||
<p>Hallo <%= @name %>,</p>
|
||||
<p>du bist für die Bietrunde am Sonntag, 22.02. angemeldet.</p>
|
||||
<p>Du kannst während der Bietrunde über den folgenden Link dein Gebot digital abgeben, wodurch das Auszählen der gebotenen Beträge deutlich beschleunigt wird.</p>
|
||||
|
||||
<p> <a href="<%= @invite_link %>">persönlicher Bietlink</a></p>
|
||||
|
||||
<p>Es handelt sich um einen personalisierten Link, der an deine Mitgliedschaft gekoppelt ist. Du wirst auf der sich öffnenden Website automatisch eingeloggt und kannst dort, sobald wir den Bietvorgang starten, dein Gebot und deine Depotwünsche eingeben.</p>
|
||||
|
||||
<p>Solltest du nicht persönlich an der Bietrunde teilnehmen, sondern jemandem eine Vollmacht erteilt haben, kannst du die Mail einfach weiterleiten und die betreffende Person kann sich über den Link an deiner Stelle einloggen.</p>
|
||||
|
||||
<p>Solltest du nicht nur für dich sondern auch noch per Vollmacht für jemand anderes bieten, ist das auch möglich: du brauchst nur den personalisierten Link der Person, für die du die Vollmacht hast. Beim Klick auf die einzelnen Links wirst du ggf. aus einem anderen Account ausgeloggt.</p>
|
||||
|
||||
<p>Wir können im Zweifelsfall auch vor Ort einen QR-Code generieren, falls der personalisierte Link nicht vorliegt, doch auf einem anderen Handy ist oder die Katze das Handy aufgegessen hat.</p>
|
||||
|
||||
<p>Außerdem ist es natürlich auch möglich ganz klassisch das Gebot auf einen Zettel zu schreiben.</p>
|
||||
|
||||
<p>Falls du online an der Bietrunde teilnehmen willst, solltest du bereits den Link zur Zoom-Veranstaltung per Mail bekommen. Sollte das nicht passieren, melde dich noch mal bei uns!</p>
|
||||
<br/>
|
||||
|
||||
<p>Viele Grüße und bis Sonntag!</p>
|
||||
<p>Eure Zebras</p>
|
||||
310
lib/beet_round_server_web/user_auth.ex
Normal file
@ -0,0 +1,310 @@
|
||||
defmodule BeetRoundServerWeb.UserAuth do
|
||||
use BeetRoundServerWeb, :verified_routes
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
|
||||
alias BeetRoundServer.Accounts
|
||||
alias BeetRoundServer.Accounts.Scope
|
||||
|
||||
# Make the remember me cookie valid for 14 days. This should match
|
||||
# the session validity setting in UserToken.
|
||||
@max_cookie_age_in_days 14
|
||||
@remember_me_cookie "_beet_round_server_web_user_remember_me"
|
||||
@remember_me_options [
|
||||
sign: true,
|
||||
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
# How old the session token should be before a new one is issued. When a request is made
|
||||
# with a session token older than this value, then a new session token will be created
|
||||
# and the session and remember-me cookies (if set) will be updated with the new token.
|
||||
# Lowering this value will result in more tokens being created by active users. Increasing
|
||||
# it will result in less time before a session token expires for a user to get issued a new
|
||||
# token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
|
||||
# the reissuing of tokens completely.
|
||||
@session_reissue_age_in_days 7
|
||||
|
||||
@doc """
|
||||
Logs the user in.
|
||||
|
||||
Redirects to the session's `:user_return_to` path
|
||||
or falls back to the `signed_in_path/1`.
|
||||
"""
|
||||
def log_in_user(conn, user, params \\ %{}) do
|
||||
user_return_to = get_session(conn, :user_return_to)
|
||||
|
||||
conn
|
||||
|> create_or_extend_session(user, params)
|
||||
|> redirect(to: user_return_to || signed_in_path(conn))
|
||||
end
|
||||
|
||||
def log_in_without_creating_cookie(conn, user) do
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
user_return_to = get_session(conn, :user_return_to)
|
||||
|
||||
conn
|
||||
|> renew_session(user)
|
||||
|> put_token_in_session(token)
|
||||
|> redirect(to: user_return_to || signed_in_path(conn))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user out.
|
||||
|
||||
It clears all session data for safety. See renew_session.
|
||||
"""
|
||||
def log_out_user(conn) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
user_token && Accounts.delete_user_session_token(user_token)
|
||||
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
BeetRoundServerWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session(nil)
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
|
||||
def log_out_user_without_redirect(conn) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
user_token && Accounts.delete_user_session_token(user_token)
|
||||
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
BeetRoundServerWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session(nil)
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates the user by looking into the session and remember me token.
|
||||
|
||||
Will reissue the session token if it is older than the configured age.
|
||||
"""
|
||||
def fetch_current_scope_for_user(conn, _opts) do
|
||||
with {token, conn} <- ensure_user_token(conn),
|
||||
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_user(user))
|
||||
|> maybe_reissue_user_session_token(user, token_inserted_at)
|
||||
else
|
||||
nil -> assign(conn, :current_scope, Scope.for_user(nil))
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_user_token(conn) do
|
||||
if token = get_session(conn, :user_token) do
|
||||
{token, conn}
|
||||
else
|
||||
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
||||
|
||||
if token = conn.cookies[@remember_me_cookie] do
|
||||
{token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Reissue the session token if it is older than the configured reissue age.
|
||||
defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
|
||||
token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
|
||||
|
||||
if token_age >= @session_reissue_age_in_days do
|
||||
create_or_extend_session(conn, user, %{})
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
# This function is the one responsible for creating session tokens
|
||||
# and storing them safely in the session and cookies. It may be called
|
||||
# either when logging in, during sudo mode, or to renew a session which
|
||||
# will soon expire.
|
||||
#
|
||||
# When the session is created, rather than extended, the renew_session
|
||||
# function will clear the session to avoid fixation attacks. See the
|
||||
# renew_session function to customize this behaviour.
|
||||
defp create_or_extend_session(conn, user, params) do
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
remember_me = get_session(conn, :user_remember_me)
|
||||
|
||||
conn
|
||||
|> renew_session(user)
|
||||
|> put_token_in_session(token)
|
||||
|> maybe_write_remember_me_cookie(token, params, remember_me)
|
||||
end
|
||||
|
||||
# Do not renew session if the user is already logged in
|
||||
# to prevent CSRF errors or data being lost in tabs that are still open
|
||||
defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do
|
||||
conn
|
||||
end
|
||||
|
||||
# This function renews the session ID and erases the whole
|
||||
# session to avoid fixation attacks. If there is any data
|
||||
# in the session you may want to preserve after log in/log out,
|
||||
# you must explicitly fetch the session data before clearing
|
||||
# and then immediately set it after clearing, for example:
|
||||
#
|
||||
# defp renew_session(conn, _user) do
|
||||
# delete_csrf_token()
|
||||
# preferred_locale = get_session(conn, :preferred_locale)
|
||||
#
|
||||
# conn
|
||||
# |> configure_session(renew: true)
|
||||
# |> clear_session()
|
||||
# |> put_session(:preferred_locale, preferred_locale)
|
||||
# end
|
||||
#
|
||||
defp renew_session(conn, _user) do
|
||||
delete_csrf_token()
|
||||
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
|
||||
do: write_remember_me_cookie(conn, token)
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, _params, true),
|
||||
do: write_remember_me_cookie(conn, token)
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
|
||||
|
||||
defp write_remember_me_cookie(conn, token) do
|
||||
conn
|
||||
|> put_session(:user_remember_me, true)
|
||||
|> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
|
||||
end
|
||||
|
||||
defp put_token_in_session(conn, token) do
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, user_session_topic(token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disconnects existing sockets for the given tokens.
|
||||
"""
|
||||
def disconnect_sessions(tokens) do
|
||||
Enum.each(tokens, fn %{token: token} ->
|
||||
BeetRoundServerWeb.Endpoint.broadcast(user_session_topic(token), "disconnect", %{})
|
||||
end)
|
||||
end
|
||||
|
||||
defp user_session_topic(token), do: "users_sessions:#{Base.url_encode64(token)}"
|
||||
|
||||
@doc """
|
||||
Handles mounting and authenticating the current_scope in LiveViews.
|
||||
|
||||
## `on_mount` arguments
|
||||
|
||||
* `:mount_current_scope` - Assigns current_scope
|
||||
to socket assigns based on user_token, or nil if
|
||||
there's no user_token or no matching user.
|
||||
|
||||
* `:require_authenticated` - Authenticates the user from the session,
|
||||
and assigns the current_scope to socket assigns based
|
||||
on user_token.
|
||||
Redirects to login page if there's no logged user.
|
||||
|
||||
## Examples
|
||||
|
||||
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
|
||||
the `current_scope`:
|
||||
|
||||
defmodule BeetRoundServerWeb.PageLive do
|
||||
use BeetRoundServerWeb, :live_view
|
||||
|
||||
on_mount {BeetRoundServerWeb.UserAuth, :mount_current_scope}
|
||||
...
|
||||
end
|
||||
|
||||
Or use the `live_session` of your router to invoke the on_mount callback:
|
||||
|
||||
live_session :authenticated, on_mount: [{BeetRoundServerWeb.UserAuth, :require_authenticated}] do
|
||||
live "/profile", ProfileLive, :index
|
||||
end
|
||||
"""
|
||||
def on_mount(:mount_current_scope, _params, session, socket) do
|
||||
{:cont, mount_current_scope(socket, session)}
|
||||
end
|
||||
|
||||
def on_mount(:require_authenticated, _params, session, socket) do
|
||||
socket = mount_current_scope(socket, session)
|
||||
|
||||
if socket.assigns.current_scope && socket.assigns.current_scope.user do
|
||||
{:cont, socket}
|
||||
else
|
||||
socket =
|
||||
socket
|
||||
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def on_mount(:require_sudo_mode, _params, session, socket) do
|
||||
socket = mount_current_scope(socket, session)
|
||||
|
||||
if Accounts.sudo_mode?(socket.assigns.current_scope.user, -10) do
|
||||
{:cont, socket}
|
||||
else
|
||||
socket =
|
||||
socket
|
||||
|> Phoenix.LiveView.put_flash(:error, "You must re-authenticate to access this page.")
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp mount_current_scope(socket, session) do
|
||||
Phoenix.Component.assign_new(socket, :current_scope, fn ->
|
||||
{user, _} =
|
||||
if user_token = session["user_token"] do
|
||||
Accounts.get_user_by_session_token(user_token)
|
||||
end || {nil, nil}
|
||||
|
||||
Scope.for_user(user)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc "Returns the path to redirect to after log in."
|
||||
# the user was already logged in, redirect to settings
|
||||
def signed_in_path(%Plug.Conn{assigns: %{current_scope: %Scope{user: %Accounts.User{}}}}) do
|
||||
~p"/biddings"
|
||||
end
|
||||
|
||||
def signed_in_path(_), do: ~p"/biddings"
|
||||
|
||||
@doc """
|
||||
Plug for routes that require the user to be authenticated.
|
||||
"""
|
||||
def require_authenticated_user(conn, _opts) do
|
||||
if conn.assigns.current_scope && conn.assigns.current_scope.user do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must log in to access this page.")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
||||
put_session(conn, :user_return_to, current_path(conn))
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(conn), do: conn
|
||||
end
|
||||
97
mix.exs
Normal file
@ -0,0 +1,97 @@
|
||||
defmodule BeetRoundServer.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :beet_round_server,
|
||||
version: "0.7.1",
|
||||
elixir: "~> 1.15",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
aliases: aliases(),
|
||||
deps: deps(),
|
||||
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||
listeners: [Phoenix.CodeReloader]
|
||||
]
|
||||
end
|
||||
|
||||
# Configuration for the OTP application.
|
||||
#
|
||||
# Type `mix help compile.app` for more information.
|
||||
def application do
|
||||
[
|
||||
mod: {BeetRoundServer.Application, []},
|
||||
extra_applications: [:logger, :runtime_tools]
|
||||
]
|
||||
end
|
||||
|
||||
def cli do
|
||||
[
|
||||
preferred_envs: [precommit: :test]
|
||||
]
|
||||
end
|
||||
|
||||
# Specifies which paths to compile per environment.
|
||||
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||
defp elixirc_paths(_), do: ["lib"]
|
||||
|
||||
# Specifies your project dependencies.
|
||||
#
|
||||
# Type `mix help deps` for examples and options.
|
||||
defp deps do
|
||||
[
|
||||
{:bcrypt_elixir, "~> 3.0"},
|
||||
{:phoenix, "~> 1.8.0"},
|
||||
{:phoenix_ecto, "~> 4.5"},
|
||||
{:ecto_sql, "~> 3.13"},
|
||||
{:postgrex, ">= 0.0.0"},
|
||||
{:phoenix_html, "~> 4.1"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:phoenix_live_view, "~> 1.1.0"},
|
||||
{:lazy_html, ">= 0.1.0", only: :test},
|
||||
{:phoenix_live_dashboard, "~> 0.8.3"},
|
||||
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
|
||||
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
|
||||
{:heroicons,
|
||||
github: "tailwindlabs/heroicons",
|
||||
tag: "v2.2.0",
|
||||
sparse: "optimized",
|
||||
app: false,
|
||||
compile: false,
|
||||
depth: 1},
|
||||
{:swoosh, "~> 1.16"},
|
||||
{:phoenix_swoosh, "~> 1.2.1"},
|
||||
{:gen_smtp, "~> 1.1"},
|
||||
{:req, "~> 0.5"},
|
||||
{:telemetry_metrics, "~> 1.0"},
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
{:gettext, "~> 0.26"},
|
||||
{:jason, "~> 1.2"},
|
||||
{:dns_cluster, "~> 0.2.0"},
|
||||
{:bandit, "~> 1.5"}
|
||||
]
|
||||
end
|
||||
|
||||
# Aliases are shortcuts or tasks specific to the current project.
|
||||
# For example, to install project dependencies and perform other setup tasks, run:
|
||||
#
|
||||
# $ mix setup
|
||||
#
|
||||
# See the documentation for `Mix` for more info on aliases.
|
||||
defp aliases do
|
||||
[
|
||||
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
||||
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
||||
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
||||
"assets.build": ["tailwind beet_round_server", "esbuild beet_round_server"],
|
||||
"assets.deploy": [
|
||||
"tailwind beet_round_server --minify",
|
||||
"esbuild beet_round_server --minify",
|
||||
"phx.digest"
|
||||
],
|
||||
precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"]
|
||||
]
|
||||
end
|
||||
end
|
||||
52
mix.lock
Normal file
@ -0,0 +1,52 @@
|
||||
%{
|
||||
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.24", "1a000a048d5971b61a9efe29a3c4144ca955afd42224998d841c5011a5354838", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0c724e6c65f197841cac49d73be4e0f9b93a7711eaa52d2d4d1b9f859c329267"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
|
||||
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
|
||||
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||
"swoosh": {:hex, :swoosh, "1.22.0", "0d65a95f89aedb5011af13295742294e309b4b4aaca556858d81e3b372b58abc", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c01ced23d8786d1ee1a03e4c16574290b2ccd6267beb8c81d081c4a34574ef6e"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
}
|
||||
112
priv/gettext/en/LC_MESSAGES/errors.po
Normal file
@ -0,0 +1,112 @@
|
||||
## `msgid`s in this file come from POT (.pot) files.
|
||||
##
|
||||
## Do not add, change, or remove `msgid`s manually here as
|
||||
## they're tied to the ones in the corresponding POT file
|
||||
## (with the same domain).
|
||||
##
|
||||
## Use `mix gettext.extract --merge` or `mix gettext.merge`
|
||||
## to merge POT files into PO files.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Language: en\n"
|
||||
|
||||
## From Ecto.Changeset.cast/4
|
||||
msgid "can't be blank"
|
||||
msgstr "Muss ausgefüllt werden!"
|
||||
|
||||
## From Ecto.Changeset.unique_constraint/3
|
||||
msgid "has already been taken"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.put_change/3
|
||||
msgid "is invalid"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_acceptance/3
|
||||
msgid "must be accepted"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_format/3
|
||||
msgid "has invalid format"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_subset/3
|
||||
msgid "has an invalid entry"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_exclusion/3
|
||||
msgid "is reserved"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_confirmation/3
|
||||
msgid "does not match confirmation"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.no_assoc_constraint/3
|
||||
msgid "is still associated with this entry"
|
||||
msgstr ""
|
||||
|
||||
msgid "are still associated with this entry"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_length/3
|
||||
msgid "should have %{count} item(s)"
|
||||
msgid_plural "should have %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be %{count} character(s)"
|
||||
msgid_plural "should be %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be %{count} byte(s)"
|
||||
msgid_plural "should be %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should have at least %{count} item(s)"
|
||||
msgid_plural "should have at least %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at least %{count} character(s)"
|
||||
msgid_plural "should be at least %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at least %{count} byte(s)"
|
||||
msgid_plural "should be at least %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should have at most %{count} item(s)"
|
||||
msgid_plural "should have at most %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at most %{count} character(s)"
|
||||
msgid_plural "should be at most %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at most %{count} byte(s)"
|
||||
msgid_plural "should be at most %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
## From Ecto.Changeset.validate_number/3
|
||||
msgid "must be less than %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be greater than %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be less than or equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be greater than or equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be equal to %{number}"
|
||||
msgstr ""
|
||||
109
priv/gettext/errors.pot
Normal file
@ -0,0 +1,109 @@
|
||||
## This is a PO Template file.
|
||||
##
|
||||
## `msgid`s here are often extracted from source code.
|
||||
## Add new translations manually only if they're dynamic
|
||||
## translations that can't be statically extracted.
|
||||
##
|
||||
## Run `mix gettext.extract` to bring this file up to
|
||||
## date. Leave `msgstr`s empty as changing them here has no
|
||||
## effect: edit them in PO (`.po`) files instead.
|
||||
## From Ecto.Changeset.cast/4
|
||||
msgid "can't be blank"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.unique_constraint/3
|
||||
msgid "has already been taken"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.put_change/3
|
||||
msgid "is invalid"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_acceptance/3
|
||||
msgid "must be accepted"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_format/3
|
||||
msgid "has invalid format"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_subset/3
|
||||
msgid "has an invalid entry"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_exclusion/3
|
||||
msgid "is reserved"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_confirmation/3
|
||||
msgid "does not match confirmation"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.no_assoc_constraint/3
|
||||
msgid "is still associated with this entry"
|
||||
msgstr ""
|
||||
|
||||
msgid "are still associated with this entry"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_length/3
|
||||
msgid "should have %{count} item(s)"
|
||||
msgid_plural "should have %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be %{count} character(s)"
|
||||
msgid_plural "should be %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be %{count} byte(s)"
|
||||
msgid_plural "should be %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should have at least %{count} item(s)"
|
||||
msgid_plural "should have at least %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at least %{count} character(s)"
|
||||
msgid_plural "should be at least %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at least %{count} byte(s)"
|
||||
msgid_plural "should be at least %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should have at most %{count} item(s)"
|
||||
msgid_plural "should have at most %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at most %{count} character(s)"
|
||||
msgid_plural "should be at most %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at most %{count} byte(s)"
|
||||
msgid_plural "should be at most %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
## From Ecto.Changeset.validate_number/3
|
||||
msgid "must be less than %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be greater than %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be less than or equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be greater than or equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be equal to %{number}"
|
||||
msgstr ""
|
||||
4
priv/repo/migrations/.formatter.exs
Normal file
@ -0,0 +1,4 @@
|
||||
[
|
||||
import_deps: [:ecto_sql],
|
||||
inputs: ["*.exs"]
|
||||
]
|
||||
@ -0,0 +1,32 @@
|
||||
defmodule BeetRoundServer.Repo.Migrations.CreateUsersAuthTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
|
||||
|
||||
create table(:users, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :email, :citext, null: false
|
||||
add :hashed_password, :string
|
||||
add :confirmed_at, :utc_datetime
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:users, [:email])
|
||||
|
||||
create table(:users_tokens, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :token, :binary, null: false
|
||||
add :context, :string, null: false
|
||||
add :sent_to, :string
|
||||
add :authenticated_at, :utc_datetime
|
||||
|
||||
timestamps(type: :utc_datetime, updated_at: false)
|
||||
end
|
||||
|
||||
create index(:users_tokens, [:user_id])
|
||||
create unique_index(:users_tokens, [:context, :token])
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,13 @@
|
||||
defmodule BeetRoundServer.Repo.Migrations.CreateBiddingRounds do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:bidding_rounds, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :round_number, :integer
|
||||
add :running, :boolean, default: false, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
end
|
||||
end
|
||||
18
priv/repo/migrations/20260211151210_create_biddings.exs
Normal file
@ -0,0 +1,18 @@
|
||||
defmodule BeetRoundServer.Repo.Migrations.CreateBiddings do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:biddings, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :bidding_round, :integer
|
||||
add :amount, :integer
|
||||
add :depot_wish_one, :string
|
||||
add :depot_wish_two, :string
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all)
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:biddings, [:user_id])
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,10 @@
|
||||
defmodule BeetRoundServer.Repo.Migrations.BiddingRoundStatusStoppedInsteadOfRunning do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:bidding_rounds) do
|
||||
add :stopped, :boolean, default: false, null: false
|
||||
remove :running
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,32 @@
|
||||
defmodule BeetRoundServer.Repo.Migrations.CreateAdminsAuthTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
|
||||
|
||||
create table(:admins, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :email, :citext, null: false
|
||||
add :hashed_password, :string
|
||||
add :confirmed_at, :utc_datetime
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:admins, [:email])
|
||||
|
||||
create table(:admins_tokens, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :admin_id, references(:admins, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :token, :binary, null: false
|
||||
add :context, :string, null: false
|
||||
add :sent_to, :string
|
||||
add :authenticated_at, :utc_datetime
|
||||
|
||||
timestamps(type: :utc_datetime, updated_at: false)
|
||||
end
|
||||
|
||||
create index(:admins_tokens, [:admin_id])
|
||||
create unique_index(:admins_tokens, [:context, :token])
|
||||
end
|
||||
end
|
||||
11
priv/repo/seeds.exs
Normal file
@ -0,0 +1,11 @@
|
||||
# Script for populating the database. You can run it as:
|
||||
#
|
||||
# mix run priv/repo/seeds.exs
|
||||
#
|
||||
# Inside the script, you can read and write to any of your
|
||||
# repositories directly:
|
||||
#
|
||||
# BeetRoundServer.Repo.insert!(%BeetRoundServer.SomeSchema{})
|
||||
#
|
||||
# We recommend using the bang functions (`insert!`, `update!`
|
||||
# and so on) as they will fail if something goes wrong.
|
||||
BIN
priv/static/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
priv/static/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
priv/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
priv/static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 791 B |
BIN
priv/static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
priv/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
priv/static/images/BeetRound.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
priv/static/images/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 16 KiB |