Compare commits

..

No commits in common. "main" and "75eb9aa316081ac73be481805be73584049fd096" have entirely different histories.

172 changed files with 18235 additions and 3467 deletions

View file

@ -1,45 +0,0 @@
# This file excludes paths from the Docker build context.
#
# By default, Docker's build context includes all files (and folders) in the
# current directory. Even if a file isn't copied into the container it is still sent to
# the Docker daemon.
#
# There are multiple reasons to exclude files from the build context:
#
# 1. Prevent nested folders from being copied into the container (ex: exclude
# /assets/node_modules when copying /assets)
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
# 3. Avoid sending files containing sensitive information
#
# More information on using .dockerignore is available here:
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
.dockerignore
# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
#
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
.git
!.git/HEAD
!.git/refs
# Common development/test artifacts
/cover/
/doc/
/test/
/tmp/
.elixir_ls
# Mix artifacts
/_build/
/deps/
*.ez
# Generated on crash by the VM
erl_crash.dump
# Static artifacts - These should be fetched and built inside the Docker image
/assets/node_modules/
/priv/static/assets/
/priv/static/cache_manifest.json

3
.gitignore vendored
View file

@ -35,7 +35,7 @@ npm-debug.log
# Since we are building assets from assets/, # Since we are building assets from assets/,
# we ignore priv/static. You may want to comment # we ignore priv/static. You may want to comment
# this depending on your deployment strategy. # this depending on your deployment strategy.
#/priv/static/ /priv/static/
# Files matching config/*.secret.exs pattern contain sensitive # Files matching config/*.secret.exs pattern contain sensitive
# data and you should not commit them into version control. # data and you should not commit them into version control.
@ -50,4 +50,3 @@ npm-debug.log
# dev # dev
TODO.md TODO.md
NOTES.md

View file

@ -1,96 +0,0 @@
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of
# Alpine to avoid DNS resolution issues in production.
#
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
# https://hub.docker.com/_/ubuntu?tab=tags
#
#
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20230109-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:1.14.3-erlang-25.2.1-debian-bullseye-20230109-slim
#
ARG ELIXIR_VERSION=1.14.3
ARG OTP_VERSION=25.2.1
ARG DEBIAN_VERSION=bullseye-20230109-slim
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
FROM ${BUILDER_IMAGE} as builder
# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git curl && \
curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && \
apt-get install -y nodejs && apt-get clean && \
rm -f /var/lib/apt/lists/*_* && npm i -g npm
# prepare build dir
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force && \
mix local.rebar --force
# set build ENV
ENV MIX_ENV="prod"
# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
COPY priv priv
COPY lib lib
COPY assets assets
# install node modules
RUN npm --prefix assets install
# compile assets
RUN mix assets.deploy
# Compile the release
RUN mix compile
# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/
COPY rel rel
RUN mix release
# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE}
RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
WORKDIR "/app"
RUN chown nobody /app
# set runner ENV
ENV MIX_ENV="prod"
# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/shift73k ./
USER nobody
CMD ["/app/bin/server"]

155
README.md
View file

@ -4,86 +4,87 @@ Calendaring app for shift-worker shift tracking, with support for CSV export and
Written in Elixir & Phoenix LiveView, with Bootstrap v5. Written in Elixir & Phoenix LiveView, with Bootstrap v5.
## Production
To run in production, you'll need to provide several environment variable values:
```bash
MIX_ENV=prod \
PHX_SERVER=true \
TZ=America/New_York \
DB_SOCK=[postgres unix socket path] \
DB_NAME=[postgres db name] \
DB_USER=[postgres db user] \
DB_PASS=[postgres db user password] \
SECRET_KEY_BASE=[phoenix secret key base] \
PHX_HOST=[server fqdn (e.g., shift.73k.us)] \
PORT=4000 \
SMTP_RELAY=[smtp server] \
SMTP_PORT=[smtp port] \
SMTP_USER=[smtp username] \
SMTP_PASS=[smtp user password] \
MAIL_REPLY_TO=reply@73k.us \
MAIL_FROM_FRIENDLY=Shift73k \
MAIL_FROM_ADDR=shift73k@73k.us \
ALLOW_REG=[open for registration? true/false] \
iex -S mix phx.server
```
### Rebuilding assets for production
```bash
# rebuild static assets:
MIX_ENV=prod mix phx.digest.clean --all
rm -rf ./priv/static/*
npm --prefix assets run build
MIX_ENV=prod mix phx.digest
# then do a new commit and push...
```
## TODO ## TODO
- [X] ~~*Proper modal to delete shifts?*~~ [2022-08-14] - [ ] Ability to edit shifts?
- [X] ~~*move runtime config out of compile-time config files, to move towards supporting releases*~~ [2023-01-28] - [ ] Proper modal to delete shifts?
- [ ] bootstrap dark mode? - [ ] Allow all-day items for notes, or require hours even for sick days?
- [ ] update tests, which are way out of date? Also I don't care? - [ ] Implement proper shift/template/assign tests (views etc)
## Deploying with docker ## Deploying
The Dockerfile will enable building a new container. I do it all with docker compose, here's an example compose yml: ### New versions
```yaml When improvements are made, we can update the deployed version like so:
version: '3.9'
services: ```shell
shift73k: cd /opt/shift73k
build: # update from master
context: ./shift73k # relative path from docker-compose.yml to shift73k repo /usr/bin/git pull 73k master
network: host # fetch prod deps & compile
container_name: www-shift73k /usr/bin/mix deps.get --only prod
restart: unless-stopped MIX_ENV=prod /usr/bin/mix compile
volumes: # perform any migrations
- /etc/timezone:/etc/timezone:ro MIX_ENV=prod /usr/bin/mix ecto.migrate
- /etc/localtime:/etc/localtime:ro # update node packages via package-lock.json
- /srv/dck/postgres/sock/postgres:/var/run/postgresql # if using unix socket /usr/bin/npm --prefix /opt/shift73k/assets/ ci
# env_file: ./shift73k.env # optionally, put your env vars in a separate file # rebuild static assets:
environment: rm -rf /opt/shift73k/priv/static/*
- PHX_SERVER=true /usr/bin/npm --prefix /opt/shift73k/assets/ run deploy
- TZ=America/New_York MIX_ENV=prod /usr/bin/mix phx.digest
- DB_SOCK=/var/run/postgresql # if using unix socket instead of db url # rebuild release
- DB_NAME=[postgres db name] # if using unix socket instead of db url MIX_ENV=prod /usr/bin/mix release --overwrite
- DB_USER=[postgres db user] # if using unix socket instead of db url # restart service
- DB_PASS=[postgres db user password] # if using unix socket instead of db url sudo /bin/systemctl restart shift73k.service
- SECRET_KEY_BASE=[phoenix secret key base] ```
- PHX_HOST=[server fqdn (e.g., shift.73k.us)]
- PORT=4000 ### systemd unit:
- SMTP_RELAY=[smtp server]
- SMTP_PORT=[smtp port] ```ini
- SMTP_USER=[smtp username] [Unit]
- SMTP_PASS=[smtp user password] Description=Shift73k service
- MAIL_REPLY_TO=reply@73k.us After=local-fs.target network.target
- MAIL_FROM_FRIENDLY=Shift73k
- MAIL_FROM_ADDR=shift73k@73k.us [Service]
- ALLOW_REG=[open for registration? true/false] Type=simple
ports: User=runuser
- 4000:4000 Group=runuser
WorkingDirectory=/opt/shift73k/_build/prod/rel/shift73k
ExecStart=/opt/shift73k/_build/prod/rel/shift73k/bin/shift73k start
ExecStop=/opt/shift73k/_build/prod/rel/shift73k/bin/shift73k stop
#EnvironmentFile=/etc/default/myApp.env
Environment=LANG=en_US.utf8
Environment=MIX_ENV=prod
#Environment=PORT=4000
LimitNOFILE=65535
UMask=0027
SyslogIdentifier=shift73k
Restart=always
[Install]
WantedBy=multi-user.target
```
### nginx config:
```conf
upstream phoenix {
server 127.0.0.1:4000 max_fails=5 fail_timeout=60s;
}
server {
location / {
allow all;
# Proxy Headers
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Cluster-Client-Ip $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_redirect off;
# WebSockets
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://phoenix;
}
}
``` ```

5
assets/.babelrc Normal file
View file

@ -0,0 +1,5 @@
{
"presets": [
"@babel/preset-env"
]
}

View file

@ -9,7 +9,6 @@
@import "bs-colors"; @import "bs-colors";
// Required || Configuration -- CONTINUED // Required || Configuration -- CONTINUED
@import "../node_modules/bootstrap/scss/maps";
@import "../node_modules/bootstrap/scss/mixins"; @import "../node_modules/bootstrap/scss/mixins";
@import "../node_modules/bootstrap/scss/utilities"; @import "../node_modules/bootstrap/scss/utilities";

View file

@ -9,6 +9,3 @@
// @import "../node_modules/@fontsource/lato/700-italic.css"; /* bold | italic */ // @import "../node_modules/@fontsource/lato/700-italic.css"; /* bold | italic */
// @import "../node_modules/@fontsource/lato/900.css"; /* black | normal */ // @import "../node_modules/@fontsource/lato/900.css"; /* black | normal */
// @import "../node_modules/@fontsource/lato/900-italic.css"; /* black | italic */ // @import "../node_modules/@fontsource/lato/900-italic.css"; /* black | italic */
/* Bootstrap Icons Font */
@import "../node_modules/bootstrap-icons/font/bootstrap-icons.css";

View file

@ -32,10 +32,6 @@ $hamburger-active-hover-opacity: $hamburger-hover-opacity !default;
background-color: $navbar-light-color; background-color: $navbar-light-color;
} }
} }
&:focus {
box-shadow: $navbar-light-toggler-border-color 0 0 0 $navbar-toggler-focus-width;
// var(--bs-navbar-toggler-focus-width);
}
} }
} }
} }
@ -55,9 +51,6 @@ $hamburger-active-hover-opacity: $hamburger-hover-opacity !default;
background-color: $navbar-dark-color; background-color: $navbar-dark-color;
} }
} }
&:focus {
box-shadow: $navbar-dark-toggler-border-color 0 0 0 $navbar-toggler-focus-width;
}
} }
} }
} }

View file

@ -0,0 +1,21 @@
/*
SVG ICON SYSTEM
per https://blog.prototypr.io/align-svg-icons-to-text-and-say-goodbye-to-font-icons-d44b3d7b26b4
*/
.icon {
display: inline-flex;
align-self: center;
}
.icon svg,
.icon img {
height: 1em;
width: 1em;
fill: currentColor;
}
.icon.baseline svg,
.icon img {
top: 0.125em;
position: relative;
}

View file

@ -4,6 +4,9 @@
/* Load Bootstrap v5 and customizations */ /* Load Bootstrap v5 and customizations */
@import "bs-load"; @import "bs-load";
/*SVG ICON SYSTEM*/
@import "svg-icons";
/* LiveView specific CSS */ /* LiveView specific CSS */
@import "phx-liveview"; @import "phx-liveview";
@ -40,7 +43,7 @@
/* style icon */ /* style icon */
.inner-addon > .icon { .inner-addon > .icon {
position: absolute; position: absolute;
padding: 0.25rem 0.5rem; padding: 0.5625rem 0.5rem;
pointer-events: none; pointer-events: none;
} }
@ -65,11 +68,3 @@
.shift-description p:last-child { .shift-description p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
/*
fix readonly form background
*/
.form-control[readonly] {
background-color: $input-disabled-bg;
}

View file

@ -1,14 +1,12 @@
const togglerBtn = document.getElementById('navbarSupportedContentToggler'); const togglerBtn = document.getElementById("navbarSupportedContentToggler");
const navbarContent = document.getElementById('navbarSupportedContent'); const navbarContent = document.getElementById("navbarSupportedContent");
if (navbarContent != null) { navbarContent.addEventListener("show.bs.collapse", () => {
navbarContent.addEventListener('show.bs.collapse', () => { console.log("opening navbar content");
console.log('opening navbar content'); togglerBtn.classList.toggle("is-active");
togglerBtn.classList.toggle('is-active'); });
});
navbarContent.addEventListener('hide.bs.collapse', () => { navbarContent.addEventListener("hide.bs.collapse", () => {
console.log('closing navbar content'); console.log("closing navbar content");
togglerBtn.classList.toggle('is-active'); togglerBtn.classList.toggle("is-active");
}); });
}

View file

@ -1,24 +1,84 @@
// We import the main SCSS file, which performs all other SCSS imports, // We need to import the CSS so that webpack will load it.
// and which vite.js will preprocess with sass. // The MiniCssExtractPlugin is used to separate it out into
import '../css/app.scss' // its own CSS file.
import "../css/app.scss";
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. // Import icons for sprite-loader
import 'phoenix_html' // navbar brand icon
import "../node_modules/bootstrap-icons/icons/calendar2-week.svg"; // brand
// menus etc
import "../node_modules/bootstrap-icons/icons/person-circle.svg"; // accounts menu
import "../node_modules/bootstrap-icons/icons/person-plus.svg"; // new user / register
import "../node_modules/bootstrap-icons/icons/door-open.svg"; // log in
import "../node_modules/bootstrap-icons/icons/door-closed.svg"; // log out
import "../node_modules/bootstrap-icons/icons/sliders.svg"; // new user / register
// forms etc
import "../node_modules/bootstrap-icons/icons/at.svg"; // email field
import "../node_modules/bootstrap-icons/icons/key.svg"; // new password field
import "../node_modules/bootstrap-icons/icons/key-fill.svg"; // pw confirm field
import "../node_modules/bootstrap-icons/icons/lock.svg"; // current pw field
import "../node_modules/bootstrap-icons/icons/shield.svg"; // role
// live tables
import "../node_modules/bootstrap-icons/icons/arrow-down-up.svg"; // sort
import "../node_modules/bootstrap-icons/icons/funnel.svg"; // filter
import "../node_modules/bootstrap-icons/icons/x-circle-fill.svg"; // clear filter
import "../node_modules/bootstrap-icons/icons/sort-down-alt.svg";
import "../node_modules/bootstrap-icons/icons/sort-up-alt.svg";
import "../node_modules/bootstrap-icons/icons/chevron-left.svg";
import "../node_modules/bootstrap-icons/icons/chevron-right.svg";
import "../node_modules/bootstrap-icons/icons/pencil.svg";
import "../node_modules/bootstrap-icons/icons/trash.svg";
// page headers
import "../node_modules/bootstrap-icons/icons/shield-lock.svg"; // reset password
import "../node_modules/bootstrap-icons/icons/arrow-repeat.svg"; // resend confirmation
import "../node_modules/@mdi/svg/svg/head-question-outline.svg"; // forgot password
import "../node_modules/bootstrap-icons/icons/people.svg"; // users management
// calendar/event icons
import "../node_modules/bootstrap-icons/icons/calendar2.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-plus.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-date.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-event.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-range.svg";
import "../node_modules/bootstrap-icons/icons/clock-history.svg"; // shift template
import "../node_modules/bootstrap-icons/icons/tag.svg";
import "../node_modules/bootstrap-icons/icons/hourglass.svg";
import "../node_modules/bootstrap-icons/icons/map.svg";
import "../node_modules/bootstrap-icons/icons/geo.svg";
import "../node_modules/bootstrap-icons/icons/justify-left.svg";
import "../node_modules/bootstrap-icons/icons/plus-circle-dotted.svg";
import "../node_modules/bootstrap-icons/icons/clipboard-plus.svg";
import "../node_modules/bootstrap-icons/icons/star.svg";
import "../node_modules/bootstrap-icons/icons/star-fill.svg";
import "../node_modules/bootstrap-icons/icons/binoculars.svg";
import "../node_modules/bootstrap-icons/icons/binoculars-fill.svg";
import "../node_modules/bootstrap-icons/icons/eraser.svg";
import "../node_modules/bootstrap-icons/icons/save.svg";
import "../node_modules/bootstrap-icons/icons/asterisk.svg";
import "../node_modules/bootstrap-icons/icons/card-list.svg";
import "../node_modules/bootstrap-icons/icons/file-earmark-spreadsheet.svg";
import "../node_modules/bootstrap-icons/icons/box-arrow-in-left.svg";
import "../node_modules/bootstrap-icons/icons/link.svg";
// import Socket for Phoenix Channels // webpack automatically bundles all modules in your
// entry points. Those entry points can be configured
// in "webpack.config.js".
//
// Import deps with the dep name or local files with a relative path, for example:
//
// import {Socket} from "phoenix"
// import socket from "./socket"
//
import "phoenix_html";
import { Socket } from "phoenix"; import { Socket } from "phoenix";
// import topbar for load progress in live reloading / liveview
import topbar from "topbar"; import topbar from "topbar";
// import LiveSocket for LiveView
import { LiveSocket } from "phoenix_live_view"; import { LiveSocket } from "phoenix_live_view";
// Bootstrap v5 js imports // Bootstrap v5 js imports
import 'bootstrap/js/dist/alert'; import "bootstrap/js/dist/alert";
import 'bootstrap/js/dist/collapse'; import "bootstrap/js/dist/collapse";
import 'bootstrap/js/dist/dropdown'; import "bootstrap/js/dist/dropdown";
// Bootstrap helpers // Bootstrap helpers
import './_hamburger-helper'; import "./_hamburger-helper";
import "./_form-validity"; import "./_form-validity";
// Bootstrap-liveview helpers // Bootstrap-liveview helpers
import { AlertRemover } from "./_alert-remover"; import { AlertRemover } from "./_alert-remover";

18098
assets/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,43 @@
{ {
"name": "vanilla", "repository": {},
"private": true, "description": " ",
"version": "0.0.0", "license": "MIT",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "deploy": "NODE_ENV=production webpack --mode production",
"build": "vite build", "watch": "webpack --mode development --watch"
"preview": "vite preview"
},
"devDependencies": {
"@fullhuman/postcss-purgecss": "^5.0.0",
"@types/node": "^18.6.5",
"@types/phoenix": "^1.5.4",
"autoprefixer": "^10.4.13",
"cssnano": "^5.1.14",
"phoenix_live_view": "^0.18.11",
"sass": "^1.54.3",
"vite": "^4.0.4"
}, },
"dependencies": { "dependencies": {
"@fontsource/lato": "^4.5.9", "@fontsource/lato": "^4.2.2",
"bootstrap": "^5.2.0", "@mdi/svg": "^5.9.55",
"bootstrap-icons": "^1.9.1", "@popperjs/core": "^2.9.2",
"hamburgers": "^1.2.1", "bootstrap": "^5.0.0-beta3",
"phoenix": "^1.6.11", "bootstrap-icons": "^1.4.1",
"phoenix_html": "^3.2.0", "hamburgers": "^1.1.3",
"heroicons": "^1.0.0",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"topbar": "^1.x" "topbar": "^1.x"
},
"devDependencies": {
"@babel/core": "^7.x",
"@babel/preset-env": "^7.x",
"autoprefixer": "^10.2.5",
"babel-loader": "^8.x",
"copy-webpack-plugin": "^8.x",
"css-loader": "^5.x",
"css-minimizer-webpack-plugin": "^1.x",
"file-loader": "^6.2.0",
"glob-all": "^3.2.1",
"mini-css-extract-plugin": "^1.x",
"postcss": "^8.2.9",
"postcss-loader": "^5.2.0",
"postcss-scss": "^3.0.5",
"purgecss-webpack-plugin": "^4.0.3",
"sass": "^1.x",
"sass-loader": "^11.x",
"svg-sprite-loader": "^6.x",
"webpack": "^5.x",
"webpack-cli": "^4.x"
} }
} }

View file

@ -1,24 +0,0 @@
const autoprefixer = require('autoprefixer')
const purgecss = require('@fullhuman/postcss-purgecss')
const cssnano = require('cssnano')
module.exports = {
plugins: [
autoprefixer,
purgecss({
content: [
"./js/**/*.js",
"../lib/*_web.ex",
"../lib/*_web/**/*.*ex"
],
safelist: {
deep: [
/^phx/
]
},
}),
cssnano({
preset: 'default',
})
],
};

5
assets/postcss.config.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
parser: require("postcss-scss"),
plugins: [require("autoprefixer")],
};

View file

@ -1,37 +0,0 @@
import { defineConfig } from "vite";
export default defineConfig(({ command }) => {
const isDev = command !== "build";
if (isDev) {
// Terminate the watcher when Phoenix quits
process.stdin.on("close", () => {
process.exit(0);
});
process.stdin.resume();
}
return {
server: {
port: 3000
},
publicDir: "static",
build: {
target: "esnext", // build for recent browsers
outDir: "../priv/static", // emit assets to priv/static
emptyOutDir: true,
sourcemap: isDev, // enable source map in dev build
manifest: false, // do not generate manifest.json
rollupOptions: {
input: {
app: "./js/app.js"
},
output: {
entryFileNames: "assets/[name].js", // remove hash
chunkFileNames: "assets/[name].js",
assetFileNames: "assets/[name][extname]"
}
}
}
};
});

105
assets/webpack.config.js Normal file
View file

@ -0,0 +1,105 @@
const path = require("path");
const glob = require("glob-all");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const SpriteLoaderPlugin = require("svg-sprite-loader/plugin");
const PurgecssPlugin = require("purgecss-webpack-plugin");
module.exports = (env, options) => {
const devMode = options.mode !== "production";
return {
entry: {
app: glob.sync("./vendor/**/*.js").concat(["./js/app.js"]),
},
output: {
path: path.resolve(__dirname, "../priv/static/js"),
filename: "[name].js",
publicPath: "/js/",
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.[s]?css$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
"sass-loader",
"postcss-loader",
],
},
{
test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
use: [
{
loader: "file-loader",
options: {
esModule: false,
name: "[name].[ext]",
outputPath: "../fonts",
},
},
],
},
{
test: /\.svg$/,
loader: "svg-sprite-loader",
options: {
extract: true,
spriteFilename: "icons.svg",
publicPath: "../images/",
symbolId: (filePath) => {
if (filePath.includes("bootstrap-icons")) {
return `bi-${path.basename(filePath).slice(0, -4)}`;
} else if (filePath.includes("@mdi")) {
return `mdi-${path.basename(filePath).slice(0, -4)}`;
} else if (filePath.includes("heroicons")) {
if (filePath.includes("outline")) {
return `hio-${path.basename(filePath).slice(0, -4)}`;
} else {
return `his-${path.basename(filePath).slice(0, -4)}`;
}
} else {
return `${path.basename(filePath).slice(0, -4)}`;
}
},
},
},
],
},
plugins: [
new MiniCssExtractPlugin({ filename: "../css/app.css" }),
new SpriteLoaderPlugin({ plainSprite: true }),
new CopyWebpackPlugin({
patterns: [{ from: "static/", to: "../" }],
}),
].concat(
devMode
? []
: [
new PurgecssPlugin({
paths: glob.sync([
"../**/*.html.leex",
"../**/*.html.eex",
"../**/views/**/*.ex",
"../**/live/**/*.ex",
"./js/**/*.js",
]),
safelist: [/phx/, /topbar/],
}),
]
),
optimization: {
minimizer: ["...", new CssMinimizerPlugin()],
},
devtool: devMode ? "source-map" : undefined,
};
};

View file

@ -5,13 +5,19 @@
# is restricted to this project. # is restricted to this project.
# General application configuration # General application configuration
import Config use Mix.Config
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase
config :shift73k, config :shift73k,
ecto_repos: [Shift73k.Repo] ecto_repos: [Shift73k.Repo]
# Custom application global variables
config :shift73k, :app_global_vars,
time_zone: "America/New_York",
mailer_reply_to: "reply_to@example.com",
mailer_from: "app_name@example.com"
# Configures the endpoint # Configures the endpoint
config :shift73k, Shift73kWeb.Endpoint, config :shift73k, Shift73kWeb.Endpoint,
url: [host: "localhost"], url: [host: "localhost"],
@ -20,18 +26,6 @@ config :shift73k, Shift73kWeb.Endpoint,
pubsub_server: Shift73k.PubSub, pubsub_server: Shift73k.PubSub,
live_view: [signing_salt: "2D4GC4ac"] live_view: [signing_salt: "2D4GC4ac"]
# 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 :shift73k, Shift73k.Mailer, adapter: Swoosh.Adapters.Local
# Swoosh API client is needed for adapters other than SMTP.
config :swoosh, :api_client, false
# Configures Elixir's Logger # Configures Elixir's Logger
config :logger, :console, config :logger, :console,
format: "$time $metadata[$level] $message\n", format: "$time $metadata[$level] $message\n",

View file

@ -1,12 +1,11 @@
import Config use Mix.Config
# Configure your database # Configure your database
config :shift73k, Shift73k.Repo, config :shift73k, Shift73k.Repo,
username: "postgres", username: "postgres",
password: "postgres", password: "postgres",
socket_dir: "/srv/dck/postgres/sock/postgres",
database: "shift73k_dev", database: "shift73k_dev",
stacktrace: true, hostname: "localhost",
show_sensitive_data_on_connection_error: true, show_sensitive_data_on_connection_error: true,
pool_size: 10 pool_size: 10
@ -23,7 +22,11 @@ config :shift73k, Shift73kWeb.Endpoint,
check_origin: false, check_origin: false,
watchers: [ watchers: [
node: [ node: [
"node_modules/vite/bin/vite.js", "node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch",
"--watch-options-stdin",
cd: Path.expand("../assets", __DIR__) cd: Path.expand("../assets", __DIR__)
] ]
] ]
@ -72,3 +75,6 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation # Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime
# Import secret config
import_config "dev.secret.exs"

View file

@ -1,4 +1,4 @@
import Config use Mix.Config
# For production, don't forget to configure the url host # For production, don't forget to configure the url host
# to something meaningful, Phoenix uses this information # to something meaningful, Phoenix uses this information
@ -49,3 +49,7 @@ config :logger, level: :info
# force_ssl: [hsts: true] # force_ssl: [hsts: true]
# #
# Check `Plug.SSL` for all available options in `force_ssl`. # Check `Plug.SSL` for all available options in `force_ssl`.
# Finally import the config/prod.secret.exs which loads secrets
# and configuration from environment variables.
import_config "prod.secret.exs"

View file

@ -1,133 +0,0 @@
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/shift73k 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 :shift73k, Shift73kWeb.Endpoint, server: true
end
if config_env() == :prod do
database_sock = System.get_env("DB_SOCK") || :false
database_name = System.get_env("DB_NAME") || :false
database_user = System.get_env("DB_USER") || :false
database_pass = System.get_env("DB_PASS") || :false
database_url = System.get_env("DATABASE_URL") || :false
if (!database_sock && !database_url) do
raise """
environment variable DATABASE_URL is missing.
can also configure with unix socket by providing
DB_SOCK, DB_NAME, DB_USER, and DB_PASS values.
"""
end
if (database_sock) do
if (!database_name) do
raise """
environment variable DB_NAME is missing.
"""
end
if (!database_user) do
raise """
environment variable DB_USER is missing.
"""
end
if (!database_pass) do
raise """
environment variable DB_PASS is missing.
"""
end
end
maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
if (database_sock) do
config :shift73k, Shift73k.Repo,
username: database_user,
password: database_pass,
socket_dir: database_sock,
database: database_name,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6
else
config :shift73k, Shift73k.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6
end
# 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 :shift73k, Shift73kWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [ip: {0, 0, 0, 0}, port: port],
secret_key_base: secret_key_base
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration:
#
# config :shift73k, Shift73k.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# For this example you need include a HTTP client required by Swoosh API client.
# Swoosh supports Hackney and Finch out of the box:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
# Swoosh mailer config
config :shift73k, Shift73k.Mailer,
adapter: Swoosh.Adapters.SMTP,
relay: System.get_env("SMTP_RELAY"),
port: System.get_env("SMTP_PORT"),
username: System.get_env("SMTP_USER"),
password: System.get_env("SMTP_PASS"),
ssl: false,
tls: :always,
auth: :always,
allowed_tls_versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"],
retries: 1,
no_mx_lookups: false
config :shift73k, :app_global_vars,
time_zone: System.get_env("TZ") || "America/New_York",
mailer_reply_to: System.get_env("MAIL_REPLY_TO") || "reply_to@example.com",
mailer_from: {
System.get_env("MAIL_FROM_FRIENDLY") || "Shift73k",
System.get_env("MAIL_FROM_ADDR") || "app_name@example.com"
},
allow_registration: System.get_env("ALLOW_REG") || :true
end

View file

@ -1,4 +1,4 @@
import Config use Mix.Config
# Only in tests, remove the complexity from the password hashing algorithm # Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1 config :bcrypt_elixir, :log_rounds, 1
@ -13,8 +13,7 @@ config :shift73k, Shift73k.Repo,
password: "postgres", password: "postgres",
database: "shift73k_test#{System.get_env("MIX_TEST_PARTITION")}", database: "shift73k_test#{System.get_env("MIX_TEST_PARTITION")}",
hostname: "localhost", hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox
pool_size: 10
# We don't run a server during test. If one is required, # We don't run a server during test. If one is required,
# you can enable the server option below. # you can enable the server option below.
@ -25,8 +24,8 @@ config :shift73k, Shift73kWeb.Endpoint,
# Print only warnings and errors during test # Print only warnings and errors during test
config :logger, level: :warn config :logger, level: :warn
# Swoosh test mailer config # Bamboo test mailer config
config :shift73k, Shift73k.Mailer, adapter: Swoosh.Adapters.Test config :shift73k, Shift73k.Mailer, adapter: Bamboo.TestAdapter
# Import secret config # Import secret config
# import_config "test.secret.exs" import_config "test.secret.exs"

View file

@ -1,7 +0,0 @@
#!/bin/ash
export MIX_ENV="prod"
cd /app
mix ecto.migrate
exec mix phx.server

View file

@ -7,32 +7,12 @@ defmodule Shift73k do
if it comes from the database, an external API or others. if it comes from the database, an external API or others.
""" """
@app_vars Application.compile_env(:shift73k, :app_global_vars, time_zone: "America/New_York")
@app_time_zone @app_vars[:time_zone]
@weekdays [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] @weekdays [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
defp get_app_config_env do def app_time_zone, do: @app_time_zone
Application.get_env(:shift73k, :app_global_vars,
time_zone: "America/New_York",
allow_registration: :true,
mailer_reply_to: "admin@example.com",
mailer_from: {"Shift73k", "shift73k@example.com"}
)
end
def weekdays, do: @weekdays def weekdays, do: @weekdays
def get_app_time_zone, do:
get_app_config_env() |> Keyword.fetch!(:time_zone) |> IO.inspect(label: "time_zone", pretty: :true)
def get_app_mailer_from, do:
get_app_config_env() |> Keyword.fetch!(:mailer_from) |> IO.inspect(label: "mailer_from", pretty: :true)
def get_app_mailer_reply_to, do:
get_app_config_env() |> Keyword.fetch!(:mailer_reply_to) |> IO.inspect(label: "mailer_reply_to", pretty: :true)
def get_app_allow_reg, do:
get_app_config_env() |> Keyword.fetch!(:allow_registration) |> get_app_allow_reg()
|> IO.inspect(label: "allow_registration", pretty: :true)
def get_app_allow_reg("false"), do: :false
def get_app_allow_reg(:false), do: :false
def get_app_allow_reg(_not_false), do: :true
end end

View file

@ -108,13 +108,6 @@ defmodule Shift73k.Accounts do
""" """
def register_user(attrs) do def register_user(attrs) do
# If attrs has atom keys, convert to string
# If attrs don't include role, put default role
attrs =
attrs
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> Map.put_new("role", registration_role())
%User{} %User{}
|> User.registration_changeset(attrs) |> User.registration_changeset(attrs)
|> Repo.insert() |> Repo.insert()

View file

@ -20,19 +20,19 @@ defmodule Shift73k.Accounts.User do
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
schema "users" do schema "users" do
field :email, :string field(:email, :string)
field :password, :string, virtual: true field(:password, :string, virtual: true)
field :hashed_password, :string field(:hashed_password, :string)
field :confirmed_at, :naive_datetime field(:confirmed_at, :naive_datetime)
field :calendar_slug, :string, default: Ecto.UUID.generate() field(:calendar_slug, :string, default: Ecto.UUID.generate())
field :role, Ecto.Enum, values: Keyword.keys(@roles), default: :user field(:role, Ecto.Enum, values: Keyword.keys(@roles), default: :user)
field :week_start_at, Ecto.Enum, values: weekdays(), default: :monday field(:week_start_at, Ecto.Enum, values: weekdays(), default: :monday)
has_many :shift_templates, ShiftTemplate has_many(:shift_templates, ShiftTemplate)
belongs_to :fave_shift_template, ShiftTemplate belongs_to(:fave_shift_template, ShiftTemplate)
has_many :shifts, Shift has_many(:shifts, Shift)
timestamps() timestamps()
end end

View file

@ -2,19 +2,12 @@ defmodule Shift73k.Accounts.UserNotifier do
alias Shift73k.Mailer alias Shift73k.Mailer
alias Shift73k.Mailer.UserEmail alias Shift73k.Mailer.UserEmail
def deliver(user_email, subject, body) do
%Swoosh.Email{} = email = UserEmail.compose(user_email, subject, body)
case Mailer.deliver(email) do
{:ok, msg} -> {:ok, msg, email}
err -> err
end
end
@doc """ @doc """
Deliver instructions to confirm account. Deliver instructions to confirm account.
""" """
def deliver_confirmation_instructions(user, url) do def deliver_confirmation_instructions(user, url) do
deliver(user.email, "Confirmation instructions", """ user
|> UserEmail.compose("Confirm Your Account", """
============================== ==============================
@ -28,13 +21,15 @@ defmodule Shift73k.Accounts.UserNotifier do
============================== ==============================
""") """)
|> Mailer.deliver_later()
end end
@doc """ @doc """
Deliver instructions to reset a user password. Deliver instructions to reset a user password.
""" """
def deliver_reset_password_instructions(user, url) do def deliver_reset_password_instructions(user, url) do
deliver(user.email, "Reset password instructions", """ user
|> UserEmail.compose("Reset Your Password", """
============================== ==============================
@ -48,13 +43,15 @@ defmodule Shift73k.Accounts.UserNotifier do
============================== ==============================
""") """)
|> Mailer.deliver_later()
end end
@doc """ @doc """
Deliver instructions to update a user email. Deliver instructions to update a user email.
""" """
def deliver_update_email_instructions(user, url) do def deliver_update_email_instructions(user, url) do
deliver(user.email, "Update email instructions", """ user
|> UserEmail.compose("Change Your E-mail", """
============================== ==============================
@ -68,5 +65,6 @@ defmodule Shift73k.Accounts.UserNotifier do
============================== ==============================
""") """)
|> Mailer.deliver_later()
end end
end end

View file

@ -15,10 +15,10 @@ defmodule Shift73k.Accounts.UserToken do
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
schema "users_tokens" do schema "users_tokens" do
field :token, :binary field(:token, :binary)
field :context, :string field(:context, :string)
field :sent_to, :string field(:sent_to, :string)
belongs_to :user, Shift73k.Accounts.User belongs_to(:user, Shift73k.Accounts.User)
timestamps(updated_at: false) timestamps(updated_at: false)
end end

View file

@ -1,3 +1,3 @@
defmodule Shift73k.Mailer do defmodule Shift73k.Mailer do
use Swoosh.Mailer, otp_app: :shift73k use Bamboo.Mailer, otp_app: :shift73k
end end

View file

@ -1,13 +1,16 @@
defmodule Shift73k.Mailer.UserEmail do defmodule Shift73k.Mailer.UserEmail do
import Swoosh.Email import Bamboo.Email
import Shift73k, only: [get_app_mailer_from: 0, get_app_mailer_reply_to: 0]
@mailer_vars Application.compile_env(:shift73k, :app_global_vars,
mailer_reply_to: "admin@example.com",
mailer_from: {"Shift73k", "shift73k@example.com"}
)
def compose(user_email, subject, body_text) do def compose(user, subject, body_text) do
new() new_email()
|> from(get_app_mailer_from()) |> from(@mailer_vars[:mailer_from])
|> to(user_email) |> to(user.email)
|> header("Reply-To", get_app_mailer_reply_to()) |> put_header("Reply-To", @mailer_vars[:mailer_reply_to])
|> subject(subject) |> subject(subject)
|> text_body(body_text) |> text_body(body_text)
end end

View file

@ -1,28 +0,0 @@
defmodule Shift73k.Release do
@moduledoc """
Used for executing DB release tasks when run in production without Mix
installed.
"""
@app :shift73k
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
end

View file

@ -86,12 +86,8 @@ defmodule Shift73k.Shifts do
** (Ecto.NoResultsError) ** (Ecto.NoResultsError)
""" """
def get_shift!(nil), do: nil
def get_shift!(id), do: Repo.get!(Shift, id) def get_shift!(id), do: Repo.get!(Shift, id)
def get_shift(nil), do: nil
def get_shift(id), do: Repo.get(Shift, id)
@doc """ @doc """
Creates a shift. Creates a shift.

View file

@ -13,7 +13,7 @@ defmodule Shift73k.Shifts.Shift do
field :time_start, :time field :time_start, :time
field :time_end, :time field :time_end, :time
belongs_to :user, Shift73k.Accounts.User belongs_to(:user, Shift73k.Accounts.User)
timestamps() timestamps()
end end

View file

@ -1,7 +1,7 @@
defmodule Shift73k.Shifts.Templates.ShiftTemplate do defmodule Shift73k.Shifts.Templates.ShiftTemplate do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
import Shift73k, only: [get_app_time_zone: 0] import Shift73k, only: [app_time_zone: 0]
alias Shift73k.Shifts alias Shift73k.Shifts
alias Shift73k.Shifts.Templates.ShiftTemplate alias Shift73k.Shifts.Templates.ShiftTemplate
@ -12,12 +12,12 @@ defmodule Shift73k.Shifts.Templates.ShiftTemplate do
field :subject, :string field :subject, :string
field :description, :string field :description, :string
field :location, :string field :location, :string
field :time_zone, :string, default: get_app_time_zone() field :time_zone, :string, default: app_time_zone()
field :time_start, :time, default: ~T[09:00:00] field :time_start, :time, default: ~T[09:00:00]
field :time_end, :time, default: ~T[17:00:00] field :time_end, :time, default: ~T[17:00:00]
belongs_to :user, Shift73k.Accounts.User belongs_to(:user, Shift73k.Accounts.User)
has_one :is_fave_of_user, Shift73k.Accounts.User, foreign_key: :fave_shift_template_id has_one(:is_fave_of_user, Shift73k.Accounts.User, foreign_key: :fave_shift_template_id)
timestamps() timestamps()
end end
@ -57,7 +57,6 @@ defmodule Shift73k.Shifts.Templates.ShiftTemplate do
[] []
end end
end) end)
|> validate_not_nil([:time_zone])
|> validate_inclusion(:time_zone, Tzdata.zone_list(), |> validate_inclusion(:time_zone, Tzdata.zone_list(),
message: "must be a valid IANA tz database time zone" message: "must be a valid IANA tz database time zone"
) )
@ -73,14 +72,4 @@ defmodule Shift73k.Shifts.Templates.ShiftTemplate do
|> Map.from_struct() |> Map.from_struct()
|> Map.drop([:__meta__, :id, :inserted_at, :updated_at, :user, :is_fave_of_user]) |> Map.drop([:__meta__, :id, :inserted_at, :updated_at, :user, :is_fave_of_user])
end end
def validate_not_nil(changeset, fields) do
Enum.reduce(fields, changeset, fn field, changeset ->
if get_field(changeset, field) == nil do
add_error(changeset, field, "nil")
else
changeset
end
end)
end
end end

View file

@ -46,7 +46,7 @@ defmodule Shift73kWeb do
def live_view do def live_view do
quote do quote do
use Phoenix.LiveView, use Phoenix.LiveView,
layout: {Shift73kWeb.LayoutView, :live} layout: {Shift73kWeb.LayoutView, "live.html"}
unquote(view_helpers()) unquote(view_helpers())
import Shift73kWeb.LiveHelpers import Shift73kWeb.LiveHelpers
@ -100,11 +100,14 @@ defmodule Shift73kWeb do
use Phoenix.HTML use Phoenix.HTML
# Import LiveView helpers (live_render, live_component, live_patch, etc) # Import LiveView helpers (live_render, live_component, live_patch, etc)
import Phoenix.Component import Phoenix.LiveView.Helpers
# Import basic rendering functionality (render, render_layout, etc) # Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View import Phoenix.View
# Import SVG Icon helper
import Shift73kWeb.IconHelpers
import Shift73kWeb.ErrorHelpers import Shift73kWeb.ErrorHelpers
import Shift73kWeb.Gettext import Shift73kWeb.Gettext
alias Shift73kWeb.Router.Helpers, as: Routes alias Shift73kWeb.Router.Helpers, as: Routes

View file

@ -10,50 +10,51 @@ defmodule Shift73kWeb.Endpoint do
signing_salt: "9CKxo0VJ" signing_salt: "9CKxo0VJ"
] ]
socket "/socket", Shift73kWeb.UserSocket, socket("/socket", Shift73kWeb.UserSocket,
websocket: true, websocket: true,
longpoll: false longpoll: false
)
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
# Serve at "/" the static files from "priv/static" directory. # Serve at "/" the static files from "priv/static" directory.
# #
# You should set gzip to true if you are running phx.digest # You should set gzip to true if you are running phx.digest
# when deploying your static files in production. # when deploying your static files in production.
# #
# file list generated by simple ls -1 priv/static/ - then copy/paste here # file list generated by simple ls -1 assets/static/ - then copy/paste here
plug Plug.Static, plug(Plug.Static,
at: "/", at: "/",
from: :shift73k, from: :shift73k,
gzip: false gzip: (Mix.env() not in [:dev, :test]),
only: "priv/static" |> Path.expand() |> File.ls!()
# For using vite.js in dev, we need to instruct Phoenix to serve files at assets/src over the usual endpoint. This is only necessary in development. )
if Mix.env() == :dev do
plug Plug.Static,
at: "/",
from: "assets",
gzip: false
end
# Code reloading can be explicitly enabled under the # Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint. # :code_reloader configuration of your endpoint.
if code_reloading? do if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
plug Phoenix.LiveReloader plug(Phoenix.LiveReloader)
plug Phoenix.CodeReloader plug(Phoenix.CodeReloader)
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :shift73k plug(Phoenix.Ecto.CheckRepoStatus, otp_app: :shift73k)
end end
plug Plug.RequestId plug(Phoenix.LiveDashboard.RequestLogger,
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] param_key: "request_logger",
cookie_key: "request_logger"
)
plug Plug.Parsers, plug(Plug.RequestId)
plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint])
plug(Plug.Parsers,
parsers: [:urlencoded, :multipart, :json], parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"], pass: ["*/*"],
json_decoder: Phoenix.json_library() json_decoder: Phoenix.json_library()
)
plug Plug.MethodOverride plug(Plug.MethodOverride)
plug Plug.Head plug(Plug.Head)
plug Plug.Session, @session_options plug(Plug.Session, @session_options)
plug Shift73kWeb.Router plug(Shift73kWeb.Router)
end end

View file

@ -1,6 +1,6 @@
defmodule Shift73kWeb.LiveHelpers do defmodule Shift73kWeb.LiveHelpers do
import Phoenix.LiveView import Phoenix.LiveView
import Phoenix.Component import Phoenix.LiveView.Helpers
alias Shift73k.Accounts alias Shift73k.Accounts
alias Shift73k.Accounts.User alias Shift73k.Accounts.User
@ -19,6 +19,27 @@ defmodule Shift73kWeb.LiveHelpers do
""" """
def live_okreply(socket), do: {:ok, socket} def live_okreply(socket), do: {:ok, socket}
@doc """
Renders a component inside the `Shift73kWeb.ModalComponent` component.
The rendered modal receives a `:return_to` option to properly update
the URL when the modal is closed.
## Examples
<%= live_modal @socket, Shift73kWeb.PropertyLive.FormComponent,
id: @property.id || :new,
action: @live_action,
property: @property,
return_to: Routes.property_index_path(@socket, :index) %>
"""
def live_modal(socket, component, opts) do
modal_opts = [id: :modal, component: component, opts: opts]
# dirty little workaround for elixir complaining about socket being unused
_socket = socket
live_component(socket, Shift73kWeb.ModalComponent, modal_opts)
end
@doc """ @doc """
Loads default assigns for liveviews Loads default assigns for liveviews
""" """

View file

@ -3,12 +3,12 @@ defmodule Shift73kWeb.ModalComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~L"""
<div id={@id} class="modal fade" <div id="<%= @id %>" class="modal fade"
phx-hook="BsModal" phx-hook="BsModal"
phx-window-keydown="hide" phx-window-keydown="hide"
phx-key="escape" phx-key="escape"
phx-target={"#" <> to_string(@id)} phx-target="#<%= @id %>"
phx-page-loading> phx-page-loading>
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@ -16,10 +16,10 @@ defmodule Shift73kWeb.ModalComponent do
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title"><%= Keyword.get(@opts, :title, "Modal title") %></h5> <h5 class="modal-title"><%= Keyword.get(@opts, :title, "Modal title") %></h5>
<button type="button" class="btn-close" phx-click="hide" phx-target={@myself} aria-label="Close"></button> <button type="button" class="btn-close" phx-click="hide" phx-target="<%= @myself %>" aria-label="Close"></button>
</div> </div>
<%= live_component @component, Keyword.put(@opts, :modal_id, @id) %> <%= live_component @socket, @component, Keyword.put(@opts, :modal_id, @id) %>
</div> </div>
</div> </div>

View file

@ -1,35 +0,0 @@
<div>
<div class="modal-body">
<p>Are you sure you want to delete all assigned shifts from the selected days?</p>
<%= for {y, data} <- @date_map do %>
<dt><%= y %></dt>
<% months = Map.keys(data) %>
<dd>
<%= for {m, i} <- Enum.with_index(months, 1) do %>
<%= data |> Map.get(m) |> hd() |> Calendar.strftime("%b") %>:
<% days = Map.get(data, m) %>
<%= for {d, i} <- Enum.with_index(days, 1) do %>
<%= d.day %><%= if i < length(days) do %>,<% end %>
<% end %>
<%= if i < length(months) do %><br /><% end %>
<% end %>
</dd>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm-delete-days-shifts",
phx_target: @myself
%>
</div>
</div>

View file

@ -0,0 +1,31 @@
<div class="modal-body">
<p>Are you sure you want to delete all assigned shifts from the selected days?</p>
<%= for {y, data} <- @date_map do %>
<dt><%= y %></dt>
<% months = Map.keys(data) %>
<dd>
<%= for {m, i} <- Enum.with_index(months, 1) do %>
<%= data |> Map.get(m) |> hd() |> Calendar.strftime("%b") %>:
<% days = Map.get(data, m) %>
<%= for {d, i} <- Enum.with_index(days, 1) do %>
<%= d.day %><%= if i < length(days) do %>,<% end %>
<% end %>
<%= if i < length(months) do %><br /><% end %>
<% end %>
</dd>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm-delete-days-shifts",
phx_target: @myself
%>
</div>

View file

@ -1,6 +1,5 @@
defmodule Shift73kWeb.ShiftAssignLive.Index do defmodule Shift73kWeb.ShiftAssignLive.Index do
use Shift73kWeb, :live_view use Shift73kWeb, :live_view
import Shift73k, only: [get_app_time_zone: 0]
alias Shift73k.Repo alias Shift73k.Repo
alias Shift73k.Shifts alias Shift73k.Shifts

View file

@ -1,20 +1,16 @@
<%= if @delete_days_shifts do %> <%= if @delete_days_shifts do %>
<.live_component <%= live_modal @socket, Shift73kWeb.ShiftAssignLive.DeleteComponent,
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.ShiftAssignLive.DeleteComponent}
opts={[
id: "delete-days-shifts-#{@current_user.id}", id: "delete-days-shifts-#{@current_user.id}",
title: "Delete Shifts From Selected Days", title: "Delete Shifts From Selected Days",
delete_days_shifts: @delete_days_shifts, delete_days_shifts: @delete_days_shifts,
current_user: @current_user current_user: @current_user
]} %>
/>
<% end %> <% end %>
<h2 class="mb-3 mb-sm-0"> <h2 class="mb-3 mb-sm-0">
<i class="bi bi-calendar2-plus me-1"></i> Schedule Shifts <%= icon_div @socket, "bi-calendar2-plus", [class: "icon baseline"] %>
Schedule Shifts
</h2> </h2>
<div class="row justify-content-center mt-4"> <div class="row justify-content-center mt-4">
@ -28,19 +24,14 @@
%> %>
<% end %> <% end %>
<% details_button_class = "ms-2 btn btn-primary text-nowrap" <button type="button" class="ms-2 btn btn-primary text-nowrap <%= if @show_template_btn_active, do: "active" %>" id="#templateDetailsBtn" phx-click="toggle-template-details" phx-value-target_id="#templateDetailsCol">
details_button_class = if @show_template_btn_active, do: "#{details_button_class} active", else: details_button_class %> <%= icon_div @socket, (@show_template_btn_active && "bi-binoculars-fill" || "bi-binoculars"), [class: "icon baseline"] %>
<button type="button" class={details_button_class} id="#templateDetailsBtn" phx-click="toggle-template-details" phx-value-target_id="#templateDetailsCol">
<i class={@show_template_btn_active && "bi bi-binoculars-fill me-sm-1" || "bi bi-binoculars me-sm-1"}></i>
<span class="d-none d-sm-inline">Details</span> <span class="d-none d-sm-inline">Details</span>
</button> </button>
</div> </div>
<div class="col-12 col-lg-9 col-xl-8 col-xxl-7 <%= @show_template_details && "collapse show" || "collapse" %>" id="#templateDetailsCol" phx-hook="BsCollapse">
<% template_details_div_class = "col-12 col-lg-9 col-xl-8 col-xxl-7 collapse"
template_details_div_class = if @show_template_details, do: "#{template_details_div_class} show", else: template_details_div_class %>
<div class={template_details_div_class} id="#templateDetailsCol" phx-hook="BsCollapse">
<div class="card mt-4"> <div class="card mt-4">
<div class="card-body"> <div class="card-body">
@ -50,8 +41,8 @@
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<%= label stf, :subject, "Subject/Title", class: "form-label" %> <%= label stf, :subject, "Subject/Title", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(stf, :subject)}> <div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(stf, :subject) %>">
<i class="bi bi-tag icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-tag", [class: "icon is-left text-muted fs-5"] %>
<%= text_input stf, :subject, <%= text_input stf, :subject,
value: input_value(stf, :subject), value: input_value(stf, :subject),
class: input_class(stf, :subject, "form-control"), class: input_class(stf, :subject, "form-control"),
@ -66,27 +57,25 @@
<div class="col-12 col-md-6 mb-3"> <div class="col-12 col-md-6 mb-3">
<div class="row gx-2 gx-sm-3"> <div class="row gx-2 gx-sm-3">
<div class="col-6" phx-feedback-for={input_id(stf, :time_start)}> <div class="col-6" phx-feedback-for="<%= input_id(stf, :time_start) %>">
<%= label stf, :time_start, "Start", class: "form-label" %> <%= label stf, :time_start, "Start", class: "form-label" %>
<%= time_input stf, :time_start, <%= time_input stf, :time_start,
precision: :minute, precision: :minute,
value: input_value(stf, :time_start), value: input_value(stf, :time_start),
class: input_class(stf, :time_start, "form-control"), class: input_class(stf, :time_start, "form-control"),
disabled: @shift_template.id != @custom_shift.id, disabled: @shift_template.id != @custom_shift.id,
aria_describedby: error_ids(stf, :time_start), aria_describedby: error_ids(stf, :time_start)
required: true
%> %>
</div> </div>
<div class="col-6" phx-feedback-for={input_id(stf, :time_end)}> <div class="col-6" phx-feedback-for="<%= input_id(stf, :time_end) %>">
<%= label stf, :time_end, "End", class: "form-label" %> <%= label stf, :time_end, "End", class: "form-label" %>
<%= time_input stf, :time_end, <%= time_input stf, :time_end,
precision: :minute, precision: :minute,
value: input_value(stf, :time_end), value: input_value(stf, :time_end),
class: input_class(stf, :time_end, "form-control"), class: input_class(stf, :time_end, "form-control"),
disabled: @shift_template.id != @custom_shift.id, disabled: @shift_template.id != @custom_shift.id,
aria_describedby: error_ids(stf, :time_end), aria_describedby: error_ids(stf, :time_end)
required: true
%> %>
</div> </div>
@ -94,18 +83,18 @@
<div class="valid-feedback d-block text-primary">Shift length: <%= @shift_length %></div> <div class="valid-feedback d-block text-primary">Shift length: <%= @shift_length %></div>
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(stf, :time_start)}> <div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(stf, :time_start) %>">
<%= error_tag stf, :time_start %> <%= error_tag stf, :time_start %>
</div> </div>
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(stf, :time_end)}> <div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(stf, :time_end) %>">
<%= error_tag stf, :time_end %> <%= error_tag stf, :time_end %>
</div> </div>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<%= label stf, :location, class: "form-label" %> <%= label stf, :location, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(stf, :location)}> <div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(stf, :location) %>">
<i class="bi bi-geo icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-geo", [class: "icon is-left text-muted fs-5"] %>
<%= text_input stf, :location, <%= text_input stf, :location,
value: input_value(stf, :location), value: input_value(stf, :location),
class: input_class(stf, :location, "form-control"), class: input_class(stf, :location, "form-control"),
@ -119,19 +108,18 @@
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<%= label stf, :time_zone, class: "form-label" %> <%= label stf, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3 mb-md-0" phx-feedback-for={input_id(stf, :time_zone)}> <div class="inner-addon left-addon mb-3 mb-md-0" phx-feedback-for="<%= input_id(stf, :time_zone) %>">
<i class="bi bi-map icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-map", [class: "icon is-left text-muted fs-5"] %>
<%= text_input stf, :time_zone, <%= text_input stf, :time_zone,
value: input_value(stf, :time_zone), value: input_value(stf, :time_zone),
class: input_class(stf, :time_zone, "form-control"), class: input_class(stf, :time_zone, "form-control"),
disabled: @shift_template.id != @custom_shift.id, disabled: @shift_template.id != @custom_shift.id,
phx_debounce: 250, phx_debounce: 250,
list: "tz_list", list: "tz_list"
placeholder: "Default: #{get_app_time_zone()}"
%> %>
<datalist id="tz_list"> <datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %> <%= for tz_name <- Tzdata.zone_list() do %>
<option value={tz_name}></option> <option value="<%= tz_name %>"></option>
<% end %> <% end %>
end end
</datalist> </datalist>
@ -144,7 +132,7 @@
<div class="col-12"> <div class="col-12">
<%= label stf, :description, class: "form-label" %> <%= label stf, :description, class: "form-label" %>
<div phx-feedback-for={input_id(stf, :description)}> <div phx-feedback-for="<%= input_id(stf, :description) %>">
<%= textarea stf, :description, <%= textarea stf, :description,
value: input_value(stf, :description), value: input_value(stf, :description),
@ -186,17 +174,17 @@
<%= Calendar.strftime(@cursor_date, "%B %Y") %> <%= Calendar.strftime(@cursor_date, "%B %Y") %>
</h3> </h3>
<div> <div>
<button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" disabled={if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: :true, else: :false}> <button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" <%= if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: "disabled" %>>
<i class="bi bi-asterisk me-sm-1"></i> <%= icon_div @socket, "bi-asterisk", [class: "icon baseline"] %>
<span class="d-none d-sm-inline">Today</span> <span class="d-none d-sm-inline">Today</span>
</button> </button>
<button type="button" phx-click="month-nav" phx-value-month="prev" class="btn btn-primary"> <button type="button" phx-click="month-nav" phx-value-month="prev" class="btn btn-primary">
<i class="bi bi-chevron-left me-sm-1"></i> <%= icon_div @socket, "bi-chevron-left", [class: "icon baseline"] %>
<span class="d-none d-sm-inline">Prev</span> <span class="d-none d-sm-inline">Prev</span>
</button> </button>
<button type="button" phx-click="month-nav" phx-value-month="next" class="btn btn-primary"> <button type="button" phx-click="month-nav" phx-value-month="next" class="btn btn-primary">
<span class="d-none d-sm-inline">Next</span> <span class="d-none d-sm-inline">Next</span>
<i class="bi bi-chevron-right ms-sm-1"></i> <%= icon_div @socket, "bi-chevron-right", [class: "icon baseline", style: "margin-left:0.125rem;"] %>
</button> </button>
</div> </div>
</div> </div>
@ -216,7 +204,7 @@
<%= for week <- @week_rows do %> <%= for week <- @week_rows do %>
<tr> <tr>
<%= for day <- week do %> <%= for day <- week do %>
<td class={day_color(day, @current_date, @cursor_date, @selected_days)} phx-click="select-day" phx-value-day={day}> <td class="<%= day_color(day, @current_date, @cursor_date, @selected_days) %>" phx-click="select-day" phx-value-day="<%= day %>">
<%= Calendar.strftime(day, "%d") %><%= if day.month != @cursor_date.month, do: "-#{Calendar.strftime(day, "%b")}" %> <%= Calendar.strftime(day, "%d") %><%= if day.month != @cursor_date.month, do: "-#{Calendar.strftime(day, "%b")}" %>
@ -248,16 +236,19 @@
<div class="row justify-content-center justify-content-lg-end my-5"> <div class="row justify-content-center justify-content-lg-end my-5">
<div class="col-12 col-sm-10 col-md-8 col-lg-auto d-flex flex-column-reverse flex-lg-row"> <div class="col-12 col-sm-10 col-md-8 col-lg-auto d-flex flex-column-reverse flex-lg-row">
<button class="btn btn-outline-danger mb-1 mb-lg-0 me-lg-1" phx-click="delete-days-shifts" disabled={if Enum.empty?(@selected_days), do: :true, else: :false}> <button class="btn btn-outline-danger mb-1 mb-lg-0 me-lg-1" phx-click="delete-days-shifts" <%= if Enum.empty?(@selected_days), do: "disabled" %>>
<i class="bi bi-trash me-1"></i> Delete shifts from selected days <%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete shifts from selected days
</button> </button>
<button class="btn btn-outline-dark mb-1 mb-lg-0 me-lg-1" phx-click="clear-days" disabled={if Enum.empty?(@selected_days), do: :true, else: :false}> <button class="btn btn-outline-dark mb-1 mb-lg-0 me-lg-1" phx-click="clear-days" <%= if Enum.empty?(@selected_days), do: "disabled" %>>
<i class="bi bi-eraser me-1"></i> De-select all selected <%= icon_div @socket, "bi-eraser", [class: "icon baseline"] %>
De-select all selected
</button> </button>
<button class="btn btn-primary mb-1 mb-lg-0" phx-click="save-days" disabled={if (!@shift_template_changeset.valid? || Enum.empty?(@selected_days)), do: :true, else: :false}> <button class="btn btn-primary mb-1 mb-lg-0" phx-click="save-days" <%= if (!@shift_template_changeset.valid? || Enum.empty?(@selected_days)), do: "disabled" %>>
<i class="bi bi-save me-1"></i> Assign shifts to selected days <%= icon_div @socket, "bi-save", [class: "icon baseline"] %>
Assign shifts to selected days
</button> </button>
</div> </div>

View file

@ -2,8 +2,10 @@
<div class="col-12 col-md-10 col-xl-8"> <div class="col-12 col-md-10 col-xl-8">
<h2> <h2>
<i class="bi bi-box-arrow-in-left me-1"></i> Import Shifts <%= icon_div @socket, "bi-box-arrow-in-left", [class: "icon baseline"] %>
Import Shifts
</h2> </h2>
<p class="lead">If you have an iCal/ics formatted calendar hosted elsewhere, provide its URL here to import its events.</p> <p class="lead">If you have an iCal/ics formatted calendar hosted elsewhere, provide its URL here to import its events.</p>
<div class="row justify-content-center"> <div class="row justify-content-center">
@ -15,7 +17,7 @@
<% valid_class = @url_validated && "is-valid" || "" %> <% valid_class = @url_validated && "is-valid" || "" %>
<%= label iimf, :ics_url, "iCal/ics URL", class: "form-label" %> <%= label iimf, :ics_url, "iCal/ics URL", class: "form-label" %>
<div class="inner-addon left-addon mb-3"> <div class="inner-addon left-addon mb-3">
<i class="bi bi-link icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-link", [class: "icon is-left text-muted fs-5"] %>
<%= url_input iimf, :ics_url, <%= url_input iimf, :ics_url,
class: show_url_error && "form-control is-invalid" || "form-control #{valid_class}", class: show_url_error && "form-control is-invalid" || "form-control #{valid_class}",
phx_debounce: 500, phx_debounce: 500,
@ -31,9 +33,9 @@
<%= label iimf, :time_zone, class: "form-label" %> <%= label iimf, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3"> <div class="inner-addon left-addon mb-3">
<i class="bi bi-map icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-map", [class: "icon is-left text-muted fs-5"] %>
<%= text_input iimf, :time_zone, <%= text_input iimf, :time_zone,
value: Shift73k.get_app_time_zone(), value: Shift73k.app_time_zone(),
class: @tz_valid && "form-control" || "form-control is-invalid", class: @tz_valid && "form-control" || "form-control is-invalid",
phx_debounce: 250, phx_debounce: 250,
aria_describedby: "ics-import-tz-error", aria_describedby: "ics-import-tz-error",
@ -41,7 +43,7 @@
%> %>
<datalist id="tz_list"> <datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %> <%= for tz_name <- Tzdata.zone_list() do %>
<option value={tz_name}></option> <option value="<%= tz_name %>"></option>
<% end %> <% end %>
</datalist> </datalist>
<div class="valid-feedback d-block text-primary">Type to search & select from list of known <%= link "IANA tz database", to: "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", target: "_blank" %> time zones</div> <div class="valid-feedback d-block text-primary">Type to search & select from list of known <%= link "IANA tz database", to: "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", target: "_blank" %> time zones</div>

View file

@ -1,46 +0,0 @@
defmodule Shift73kWeb.ShiftLive.DeleteComponent do
use Shift73kWeb, :live_component
alias Shift73k.Shifts
@impl true
def update(assigns, socket) do
socket
|> assign(assigns)
|> live_okreply()
end
@impl true
def handle_event("confirm", %{"id" => id, "subject" => subject, "datetime" => datetime}, socket) do
shift = Shifts.get_shift(id)
if (shift) do
shift
|> Shifts.delete_shift()
|> case do
{:ok, _} ->
flash = {:info, "Shift deleted successfully: \"#{subject}\""}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
{:error, _} ->
handle_error(socket, subject, datetime)
end
end
end
defp handle_error(socket, subject, datetime) do
flash =
{:error,
"Some error trying to delete shift \"#{subject} (#{datetime})\". Possibly already deleted? Reloading list..."}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
end
end

View file

@ -1,22 +0,0 @@
<div>
<div class="modal-body">
<% shift_datetime = "#{Calendar.strftime(@delete_shift.date, "%A, %b %-d")}, #{format_shift_time(@delete_shift.time_start)} — #{format_shift_time(@delete_shift.time_end)}" %>
Are you sure you want to delete "<%= @delete_shift.subject %> (<%= shift_datetime %>)?"
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_shift.id,
phx_value_subject: @delete_shift.subject,
phx_value_datetime: shift_datetime %>
</div>
</div>

View file

@ -22,7 +22,6 @@ defmodule Shift73kWeb.ShiftLive.Index do
socket socket
|> init_today(Date.utc_today()) |> init_today(Date.utc_today())
|> update_agenda() |> update_agenda()
|> assign_modal_close_handlers()
|> assign(:delete_shift, nil) |> assign(:delete_shift, nil)
|> apply_action(socket.assigns.live_action, params) |> apply_action(socket.assigns.live_action, params)
|> live_noreply() |> live_noreply()
@ -34,11 +33,6 @@ defmodule Shift73kWeb.ShiftLive.Index do
end end
end end
defp assign_modal_close_handlers(socket) do
to = Routes.shift_index_path(socket, :index)
assign(socket, modal_return_to: to, modal_close_action: :return)
end
defp apply_action(socket, :index, _params) do defp apply_action(socket, :index, _params) do
socket socket
|> assign(:page_title, "My Shifts") |> assign(:page_title, "My Shifts")
@ -82,14 +76,6 @@ defmodule Shift73kWeb.ShiftLive.Index do
|> assign_known_shifts() |> assign_known_shifts()
end end
@impl true
def handle_event("delete-modal", %{"id" => id}, socket) do
socket
|> assign(:modal_close_action, :delete_shift)
|> assign(:delete_shift, Shifts.get_shift!(id))
|> live_noreply()
end
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
shift = Shifts.get_shift!(id) shift = Shifts.get_shift!(id)
@ -108,28 +94,6 @@ defmodule Shift73kWeb.ShiftLive.Index do
|> live_noreply() |> live_noreply()
end end
@impl true
def handle_info({:close_modal, _}, %{assigns: %{modal_close_action: :return}} = socket) do
socket
|> copy_flash()
|> push_patch(to: socket.assigns.modal_return_to)
|> live_noreply()
end
@impl true
def handle_info({:close_modal, _}, %{assigns: %{modal_close_action: assign_key}} = socket) do
socket
|> assign(assign_key, nil)
|> assign_modal_close_handlers()
|> assign_known_shifts()
|> live_noreply()
end
@impl true
def handle_info({:put_flash_message, {flash_type, msg}}, socket) do
socket |> put_flash(flash_type, msg) |> live_noreply()
end
defp new_nav_cursor("now", _cursor_date), do: Date.utc_today() defp new_nav_cursor("now", _cursor_date), do: Date.utc_today()
defp new_nav_cursor(nav, cursor_date) do defp new_nav_cursor(nav, cursor_date) do

View file

@ -1,48 +1,40 @@
<%= if @delete_shift do %>
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.ShiftLive.DeleteComponent}
opts={[
id: @delete_shift.id,
title: "Delete Shift Template",
delete_shift: @delete_shift
]}
/>
<% end %>
<div class="row justify-content-start justify-content-sm-center"> <div class="row justify-content-start justify-content-sm-center">
<div class="col-md-10 col-xl-10"> <div class="col-md-10 col-xl-10">
<h2 class="mb-3 mb-sm-0"> <h2 class="mb-3 mb-sm-0">
<i class="bi bi-card-list me-1"></i> My Shifts <%= icon_div @socket, "bi-card-list", [class: "icon baseline"] %>
My Shifts
</h2> </h2>
<div class="row justify-content-start justify-content-sm-center"> <div class="row justify-content-start justify-content-sm-center">
<div class="col-md-10 col-xl-10"> <div class="col-md-10 col-xl-10">
<%# month navigation %> <%# month navigation %>
<div class="d-flex justify-content-between align-items-end my-4"> <div class="d-flex justify-content-between align-items-end my-4">
<h3 class="text-muted mb-0"> <h3 class="text-muted mb-0">
<%= Calendar.strftime(@cursor_date, "%B %Y") %> <%= Calendar.strftime(@cursor_date, "%B %Y") %>
</h3> </h3>
<div> <div>
<button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" disabled={if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: :true, else: :false}> <button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" <%= if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: "disabled" %>>
<i class="bi bi-asterisk me-sm-1"></i> <%= icon_div @socket, "bi-asterisk", [class: "icon baseline"] %>
<span class="d-none d-sm-inline">Today</span> <span class="d-none d-sm-inline">Today</span>
</button> </button>
<button type="button" phx-click="month-nav" phx-value-month="prev" class="btn btn-primary"> <button type="button" phx-click="month-nav" phx-value-month="prev" class="btn btn-primary">
<i class="bi bi-chevron-left me-sm-1"></i> <%= icon_div @socket, "bi-chevron-left", [class: "icon baseline"] %>
<span class="d-none d-sm-inline">Prev</span> <span class="d-none d-sm-inline">Prev</span>
</button> </button>
<button type="button" phx-click="month-nav" phx-value-month="next" class="btn btn-primary"> <button type="button" phx-click="month-nav" phx-value-month="next" class="btn btn-primary">
<span class="d-none d-sm-inline">Next</span> <span class="d-none d-sm-inline">Next</span>
<i class="bi bi-chevron-right ms-sm-1"></i> <%= icon_div @socket, "bi-chevron-right", [class: "icon baseline", style: "margin-left:0.125rem;"] %>
</button> </button>
</div> </div>
</div> </div>
<%= for day <- Enum.to_list(@date_range) do %> <%= for day <- Enum.to_list(@date_range) do %>
<%= if Date.day_of_week(day, @current_user.week_start_at) == 1 do %> <%= if Date.day_of_week(day, @current_user.week_start_at) == 1 do %>
<div class="border-top mt-4 mb-4"></div> <div class="border-top mt-4 mb-4"></div>
@ -53,21 +45,24 @@
<% day_shifts = Enum.filter(@shifts, fn s -> s.date == day end) %> <% day_shifts = Enum.filter(@shifts, fn s -> s.date == day end) %>
<%= if !Enum.empty?(day_shifts) do %> <%= if !Enum.empty?(day_shifts) do %>
<%= for shift <- day_shifts do %> <%= for shift <- day_shifts do %>
<div class="card mt-2 mb-4 col-12 ms-sm-3 ms-md-4 col-lg-10 ms-lg-5 col-xxl-8" id={"shift-#{shift.id}"}>
<div class="card mt-2 mb-4 col-12 ms-sm-3 ms-md-4 col-lg-10 ms-lg-5 col-xxl-8" id="shift-<%= shift.id %>">
<div class="card-body"> <div class="card-body">
<h5 class="card-title"> <h5 class="card-title">
<i class="bi bi-tag text-muted me-1"></i> <%= icon_div @socket, "bi-tag", [class: "icon baseline text-muted me-1"] %>
<%= shift.subject %> <%= shift.subject %>
</h5> </h5>
<table class="table table-borderless table-nonfluid table-sm"> <table class="table table-borderless table-nonfluid table-sm">
<tbody> <tbody>
<tr> <tr>
<th scope="row" class="text-end"> <th scope="row" class="text-end">
<i class="bi bi-hourglass text-muted"></i> <%= icon_div @socket, "bi-hourglass", [class: "icon baseline text-muted"] %>
<span class="visually-hidden">Hours:</span> <span class="visually-hidden">Hours:</span>
</th> </th>
<td> <td>
@ -84,7 +79,7 @@
<tr> <tr>
<th scope="row" class="text-end"> <th scope="row" class="text-end">
<i class="bi bi-geo text-muted"></i> <%= icon_div @socket, "bi-geo", [class: "icon baseline text-muted"] %>
<span class="visually-hidden">Location:</span> <span class="visually-hidden">Location:</span>
</th> </th>
<td> <td>
@ -97,7 +92,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row" class="text-end"> <th scope="row" class="text-end">
<i class="bi bi-justify-left text-muted"></i> <%= icon_div @socket, "bi-justify-left", [class: "icon baseline text-muted"] %>
<span class="visually-hidden">Description:</span> <span class="visually-hidden">Description:</span>
</th> </th>
<td class="shift-description"> <td class="shift-description">
@ -111,22 +106,51 @@
</tbody> </tbody>
</table> </table>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={shift.id}> <%#= if Roles.can?(@current_user, template, :edit) do %>
<i class="bi bi-trash me-1"></i> Delete <%#= live_patch to: Routes.shift_template_index_path(@socket, :edit, template), class: "btn btn-primary btn-sm text-nowrap" do %>
</button> <%#= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
<%# Edit %>
<%# end %>
<%# end %>
<%#= if Roles.can?(@current_user, template, :delete) do %>
<%# <button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id=" %>
<%#= shift.id %>
<%# "> %>
<%#= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
<%# Delete %>
<%# </button> %>
<%# end %>
<%= button to: "#", phx_click: "delete", phx_value_id: shift.id, data: [confirm: "Are you sure?"], class: "btn btn-outline-danger btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
<% end %>
</div> </div>
</div> </div>
<% end %> <% end %>
<% else %> <% else %>
<p class="text-muted"><em>Nothing scheduled</em></p> <p class="text-muted"><em>Nothing scheduled</em></p>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,23 +0,0 @@
<div>
<div class="modal-body">
Are you sure you want to delete "<%= @delete_shift_template.subject %>
(<%= format_shift_time(@delete_shift_template.time_start) %>
&mdash;
<%= format_shift_time(@delete_shift_template.time_end) %>)"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_shift_template.id,
phx_value_subject: @delete_shift_template.subject %>
</div>
</div>

View file

@ -0,0 +1,19 @@
<div class="modal-body">
Are you sure you want to delete "<%= @delete_shift_template.subject %>
(<%= format_shift_time(@delete_shift_template.time_start) %>
&mdash;
<%= format_shift_time(@delete_shift_template.time_end) %>)"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_shift_template.id,
phx_value_subject: @delete_shift_template.subject %>
</div>

View file

@ -1,6 +1,5 @@
defmodule Shift73kWeb.ShiftTemplateLive.FormComponent do defmodule Shift73kWeb.ShiftTemplateLive.FormComponent do
use Shift73kWeb, :live_component use Shift73kWeb, :live_component
import Shift73k, only: [get_app_time_zone: 0]
alias Shift73k.Shifts.Templates alias Shift73k.Shifts.Templates
alias Shift73k.Shifts.Templates.ShiftTemplate alias Shift73k.Shifts.Templates.ShiftTemplate

View file

@ -1,119 +0,0 @@
<div>
<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save" phx-target={@myself} id="shift_template-form">
<div class="modal-body">
<%= label f, :subject, "Subject/Title", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :subject)}>
<i class="bi bi-tag icon is-left text-muted fs-5"></i>
<%= text_input f, :subject,
value: input_value(f, :subject),
class: input_class(f, :subject, "form-control"),
autofocus: true,
phx_debounce: 250,
aria_describedby: error_ids(f, :subject)
%>
<%= error_tag f, :subject %>
</div>
<div class="row gx-2 gx-sm-3">
<div class="col-6" phx-feedback-for={input_id(f, :time_start)}>
<%= label f, :time_start, "Start", class: "form-label" %>
<%= time_input f, :time_start,
precision: :minute,
value: input_value(f, :time_start),
class: input_class(f, :time_start, "form-control"),
aria_describedby: error_ids(f, :time_start),
required: true
%>
</div>
<div class="col-6" phx-feedback-for={input_id(f, :time_end)}>
<%= label f, :time_end, "End", class: "form-label" %>
<%= time_input f, :time_end,
precision: :minute,
value: input_value(f, :time_end),
class: input_class(f, :time_end, "form-control"),
aria_describedby: error_ids(f, :time_end),
required: true
%>
</div>
</div>
<div class="valid-feedback d-block text-primary">Shift length: <%= @shift_length %></div>
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(f, :time_start)}>
<%= error_tag f, :time_start %>
</div>
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(f, :time_end)}>
<%= error_tag f, :time_end %>
</div>
<%= label f, :location, class: "form-label mt-3" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :location)}>
<i class="bi bi-geo icon is-left text-muted fs-5"></i>
<%= text_input f, :location,
value: input_value(f, :location),
class: input_class(f, :location, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :location)
%>
<%= error_tag f, :location %>
</div>
<%= label f, :description, class: "form-label" %>
<div class="mb-3" phx-feedback-for={input_id(f, :description)}>
<%= textarea f, :description,
value: input_value(f, :description),
class: input_class(f, :description, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :description)
%>
<%= error_tag f, :description %>
</div>
<%= label f, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :time_zone)}>
<i class="bi bi-map icon is-left text-muted fs-5"></i>
<%= text_input f, :time_zone,
value: input_value(f, :time_zone),
class: input_class(f, :time_zone, "form-control"),
phx_debounce: 250,
list: "tz_list",
placeholder: "Default: #{get_app_time_zone()}"
%>
<datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %>
<option value={tz_name}></option>
<% end %>
</datalist>
<div class="valid-feedback d-block text-primary">Type to search & select from list of known <%= link "IANA tz database", to: "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", target: "_blank" %> time zones</div>
<%= error_tag f, :time_zone %>
</div>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
</.form>
</div>

View file

@ -0,0 +1,116 @@
<%= f = form_for @changeset, "#",
id: "shift_template-form",
phx_target: @myself,
phx_change: "validate",
phx_submit: "save" %>
<div class="modal-body">
<%= label f, :subject, "Subject/Title", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :subject) %>">
<%= icon_div @socket, "bi-tag", [class: "icon is-left text-muted fs-5"] %>
<%= text_input f, :subject,
value: input_value(f, :subject),
class: input_class(f, :subject, "form-control"),
autofocus: true,
phx_debounce: 250,
aria_describedby: error_ids(f, :subject)
%>
<%= error_tag f, :subject %>
</div>
<div class="row gx-2 gx-sm-3">
<div class="col-6" phx-feedback-for="<%= input_id(f, :time_start) %>">
<%= label f, :time_start, "Start", class: "form-label" %>
<%= time_input f, :time_start,
precision: :minute,
value: input_value(f, :time_start),
class: input_class(f, :time_start, "form-control"),
aria_describedby: error_ids(f, :time_start)
%>
</div>
<div class="col-6" phx-feedback-for="<%= input_id(f, :time_end) %>">
<%= label f, :time_end, "End", class: "form-label" %>
<%= time_input f, :time_end,
precision: :minute,
value: input_value(f, :time_end),
class: input_class(f, :time_end, "form-control"),
aria_describedby: error_ids(f, :time_end)
%>
</div>
</div>
<div class="valid-feedback d-block text-primary">Shift length: <%= @shift_length %></div>
<div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(f, :time_start) %>">
<%= error_tag f, :time_start %>
</div>
<div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(f, :time_end) %>">
<%= error_tag f, :time_end %>
</div>
<%= label f, :location, class: "form-label mt-3" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :location) %>">
<%= icon_div @socket, "bi-geo", [class: "icon is-left text-muted fs-5"] %>
<%= text_input f, :location,
value: input_value(f, :location),
class: input_class(f, :location, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :location)
%>
<%= error_tag f, :location %>
</div>
<%= label f, :description, class: "form-label" %>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :description) %>">
<%= textarea f, :description,
value: input_value(f, :description),
class: input_class(f, :description, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :description)
%>
<%= error_tag f, :description %>
</div>
<%= label f, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :time_zone) %>">
<%= icon_div @socket, "bi-map", [class: "icon is-left text-muted fs-5"] %>
<%= text_input f, :time_zone,
value: input_value(f, :time_zone),
class: input_class(f, :time_zone, "form-control"),
phx_debounce: 250,
list: "tz_list"
%>
<datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %>
<option value="<%= tz_name %>"></option>
<% end %>
</datalist>
<div class="valid-feedback d-block text-primary">Type to search & select from list of known <%= link "IANA tz database", to: "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", target: "_blank" %> time zones</div>
<%= error_tag f, :time_zone %>
</div>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
</form>

View file

@ -1,29 +1,17 @@
<%= if @live_action in [:new, :edit, :clone] do %> <%= if @live_action in [:new, :edit, :clone] do %>
<.live_component <%= live_modal @socket, Shift73kWeb.ShiftTemplateLive.FormComponent,
module={Shift73kWeb.ModalComponent} id: @shift_template.id || :new,
id="modal" title: @page_title,
component={Shift73kWeb.ShiftTemplateLive.FormComponent} action: @live_action,
opts={[ shift_template: @shift_template,
id: @shift_template.id || :new, current_user: @current_user %>
title: @page_title,
action: @live_action,
shift_template: @shift_template,
current_user: @current_user
]}
/>
<% end %> <% end %>
<%= if @delete_shift_template do %> <%= if @delete_shift_template do %>
<.live_component <%= live_modal @socket, Shift73kWeb.ShiftTemplateLive.DeleteComponent,
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.ShiftTemplateLive.DeleteComponent}
opts={[
id: @delete_shift_template.id, id: @delete_shift_template.id,
title: "Delete Shift Template", title: "Delete Shift Template",
delete_shift_template: @delete_shift_template delete_shift_template: @delete_shift_template %>
]}
/>
<% end %> <% end %>
@ -32,10 +20,12 @@
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center"> <div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center">
<h2 class="mb-3 mb-sm-0"> <h2 class="mb-3 mb-sm-0">
<i class="bi bi-clock-history me-1"></i> My Shift Templates <%= icon_div @socket, "bi-clock-history", [class: "icon baseline"] %>
My Shift Templates
</h2> </h2>
<%= live_patch to: Routes.shift_template_index_path(@socket, :new), class: "btn btn-primary" do %> <%= live_patch to: Routes.shift_template_index_path(@socket, :new), class: "btn btn-primary" do %>
<i class="bi bi-plus-circle-dotted me-1"></i> New Shift Template <%= icon_div @socket, "bi-plus-circle-dotted", [class: "icon baseline"] %>
New Shift Template
<% end %> <% end %>
</div> </div>
@ -49,13 +39,13 @@
<div class="card mt-4"> <div class="card mt-4">
<h5 class="card-header d-flex justify-content-between align-items-center"> <h5 class="card-header d-flex justify-content-between align-items-center">
<span class="visually-hidden">Subject:</span> <span class="visually-hidden">Subject:</span>
<i class="bi bi-tag me-1"></i> <%= icon_div @socket, "bi-tag", [class: "icon baseline me-1"] %>
<div class="w-100"><%= template.subject %></div> <div class="w-100"><%= template.subject %></div>
<% fav_icon_data = if template.id == @current_user.fave_shift_template_id, do: <%= if template.id == @current_user.fave_shift_template_id do %>
{"bi-star-fill", "Unset as favorite", "unset-user-fave-shift-template"}, else: <%= icon_div @socket, "bi-star-fill", [class: "icon baseline text-primary align-self-start ms-2"], [role: "img", aria_hidden: false, aria_label: "Unset as favorite", phx_click: "unset-user-fave-shift-template", class: "cursor-pointer"] %>
{"bi-star", "Set as favorite", "set-user-fave-shift-template"} <% else %>
%> <%= icon_div @socket, "bi-star", [class: "icon baseline text-primary align-self-start ms-2"], [role: "img", aria_hidden: false, aria_label: "Set as favorite", phx_click: "set-user-fave-shift-template", phx_value_id: template.id, class: "cursor-pointer"] %>
<i class={"bi #{elem(fav_icon_data, 0)} text-primary align-self-start ms-2 cursor-pointer"} role="img" aria-hidden="false" aria-label={elem(fav_icon_data, 1)} phx-click={elem(fav_icon_data, 2)} phx-value-id={template.id}></i> <% end %>
</h5> </h5>
<div class="card-body"> <div class="card-body">
@ -63,7 +53,7 @@
<tbody> <tbody>
<tr> <tr>
<th scope="row" class="text-end"> <th scope="row" class="text-end">
<i class="bi bi-hourglass text-muted"></i> <%= icon_div @socket, "bi-hourglass", [class: "icon baseline text-muted"] %>
<span class="visually-hidden">Hours:</span> <span class="visually-hidden">Hours:</span>
</th> </th>
<td> <td>
@ -80,7 +70,7 @@
<tr> <tr>
<th scope="row" class="text-end"> <th scope="row" class="text-end">
<i class="bi bi-geo text-muted"></i> <%= icon_div @socket, "bi-geo", [class: "icon baseline text-muted"] %>
<span class="visually-hidden">Location:</span> <span class="visually-hidden">Location:</span>
</th> </th>
<td> <td>
@ -93,7 +83,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row" class="text-end"> <th scope="row" class="text-end">
<i class="bi bi-justify-left text-muted"></i> <%= icon_div @socket, "bi-justify-left", [class: "icon baseline text-muted"] %>
<span class="visually-hidden">Description:</span> <span class="visually-hidden">Description:</span>
</th> </th>
<td> <td>
@ -109,19 +99,24 @@
<%= if Roles.can?(@current_user, template, :edit) do %> <%= if Roles.can?(@current_user, template, :edit) do %>
<%= live_patch to: Routes.shift_template_index_path(@socket, :edit, template), class: "btn btn-primary btn-sm text-nowrap" do %> <%= live_patch to: Routes.shift_template_index_path(@socket, :edit, template), class: "btn btn-primary btn-sm text-nowrap" do %>
<i class="bi bi-pencil me-1"></i> Edit <%= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
Edit
<% end %> <% end %>
<% end %> <% end %>
<%= if Roles.can?(@current_user, template, :clone) do %> <%= if Roles.can?(@current_user, template, :clone) do %>
<%= live_patch to: Routes.shift_template_index_path(@socket, :clone, template), class: "btn btn-outline-primary btn-sm text-nowrap" do %> <%= live_patch to: Routes.shift_template_index_path(@socket, :clone, template), class: "btn btn-outline-primary btn-sm text-nowrap" do %>
<i class="bi bi-clipboard-plus me-1"></i> Clone <%= icon_div @socket, "bi-clipboard-plus", [class: "icon baseline"] %>
Clone
<% end %> <% end %>
<% end %> <% end %>
<%#= button "" %>
<%= if Roles.can?(@current_user, template, :delete) do %> <%= if Roles.can?(@current_user, template, :delete) do %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={template.id}> <button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id="<%= template.id %>">
<i class="bi bi-trash me-1"></i> Delete <%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
</button> </button>
<% end %> <% end %>

View file

@ -1,6 +1,6 @@
defmodule Shift73kWeb.UserLive.Registration do defmodule Shift73kWeb.UserLive.Registration do
use Shift73kWeb, :live_view use Shift73kWeb, :live_view
alias Shift73k.Repo
alias Shift73k.Accounts alias Shift73k.Accounts
alias Shift73k.Accounts.User alias Shift73k.Accounts.User
@ -20,7 +20,9 @@ defmodule Shift73kWeb.UserLive.Registration do
user_id: nil, user_id: nil,
user_return_to: Map.get(session, "user_return_to", "/"), user_return_to: Map.get(session, "user_return_to", "/"),
messages: [ messages: [
success: "Welcome! Your new account has been created, and you've been logged in." success: "Welcome! Your new account has been created, and you've been logged in.",
info:
"Some features may be unavailable until you confirm your email address. Check your inbox for instructions."
] ]
} }
end end
@ -33,33 +35,19 @@ defmodule Shift73kWeb.UserLive.Registration do
@impl true @impl true
def handle_event("save", %{"user" => user_params}, socket) do def handle_event("save", %{"user" => user_params}, socket) do
is_first_user = !Repo.exists?(User)
user_params user_params
|> Map.put("role", Accounts.registration_role())
|> Accounts.register_user() |> Accounts.register_user()
|> case do |> case do
{:ok, user} -> {:ok, user} ->
# If this is the first user, we just confirm them {:ok, %Bamboo.Email{}} =
if is_first_user do Accounts.deliver_user_confirmation_instructions(
user |> User.confirm_changeset() |> Repo.update() user,
else &Routes.user_confirmation_url(socket, :confirm, &1)
# Otherwise, all new users require email confirmation so we wend instructions )
{:ok, _, %Swoosh.Email{} = _captured_email} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(socket, :confirm, &1)
)
end
login_params =
if is_first_user do
socket.assigns.login_params
else
put_in(socket.assigns.login_params, [:messages, :info], "Some features may be unavailable until you confirm your email address. Check your inbox for instructions.")
end
|> put_in([:user_id], user.id)
socket socket
|> assign(login_params: login_params) |> assign(login_params: %{socket.assigns.login_params | user_id: user.id})
|> assign(trigger_submit: true) |> assign(trigger_submit: true)
|> live_noreply() |> live_noreply()

View file

@ -1,16 +1,15 @@
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3"> <div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<h2> <h2><%= icon_div @socket, "bi-person-plus", [class: "icon baseline"] %>
<i class="bi bi-journal-plus me-1"></i> Register Register</h2>
</h2>
<p class="lead">Create an account to manage your work shifts with us.</p> <p class="lead">Create an account to manage your work shifts with us.</p>
<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save" novalidate id="reg_form"> <%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, novalidate: true, id: "reg_form"], fn f -> %>
<%= label f, :email, class: "form-label" %> <%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :email)}> <div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :email) %>">
<i class="bi bi-at icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<%= email_input f, :email, <%= email_input f, :email,
value: input_value(f, :email), value: input_value(f, :email),
class: input_class(f, :email, "form-control"), class: input_class(f, :email, "form-control"),
@ -24,8 +23,8 @@
</div> </div>
<%= label f, :password, class: "form-label" %> <%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}> <div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<i class="bi bi-key icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<%= password_input f, :password, <%= password_input f, :password,
value: input_value(f, :password), value: input_value(f, :password),
class: input_class(f, :password, "form-control"), class: input_class(f, :password, "form-control"),
@ -45,7 +44,7 @@
%> %>
</div> </div>
</.form> <% end %>
<p> <p>
<%= link "Log in", to: Routes.user_session_path(@socket, :new) %> | <%= link "Log in", to: Routes.user_session_path(@socket, :new) %> |

View file

@ -1,12 +1,9 @@
defmodule Shift73kWeb.UserLive.ResetPassword do defmodule Shift73kWeb.UserLive.ResetPassword do
use Shift73kWeb, :live_view use Shift73kWeb, :live_view
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Accounts alias Shift73k.Accounts
alias Shift73k.Accounts.User alias Shift73k.Accounts.User
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
user = Accounts.get_user!(session["user_id"]) user = Accounts.get_user!(session["user_id"])
@ -40,6 +37,4 @@ defmodule Shift73kWeb.UserLive.ResetPassword do
|> assign(changeset: changeset)} |> assign(changeset: changeset)}
end end
end end
def allow_registration, do: get_app_allow_reg()
end end

View file

@ -2,15 +2,16 @@
<div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3"> <div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<h2> <h2>
<i class="bi bi-shield-lock me-1"></i> Reset password <%= icon_div @socket, "bi-shield-lock", [class: "icon baseline"] %>
Reset password
</h2> </h2>
<p class="lead">Hi <%= @user.email %> &mdash; tell us your new password, please.</p> <p class="lead">Hi <%= @user.email %> &mdash; tell us your new password, please.</p>
<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save" novalidate id="pw_reset_form"> <%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, novalidate: true, id: "pw_reset_form"], fn f -> %>
<%= label f, :password, "New password", class: "form-label" %> <%= label f, :password, "New password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}> <div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<i class="bi bi-key icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<%= password_input f, :password, <%= password_input f, :password,
value: input_value(f, :password), value: input_value(f, :password),
class: input_class(f, :password, "form-control"), class: input_class(f, :password, "form-control"),
@ -22,8 +23,8 @@
</div> </div>
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %> <%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password_confirmation)}> <div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password_confirmation) %>">
<i class="bi bi-key-fill icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-key-fill", [class: "icon is-left text-muted fs-5"] %>
<%= password_input f, :password_confirmation, <%= password_input f, :password_confirmation,
value: input_value(f, :password_confirmation), value: input_value(f, :password_confirmation),
class: input_class(f, :password_confirmation, "form-control"), class: input_class(f, :password_confirmation, "form-control"),
@ -42,12 +43,10 @@
%> %>
</div> </div>
</.form> <% end %>
<p class="mt-3 is-pulled-right"> <p class="mt-3 is-pulled-right">
<%= if allow_registration() do %> <%= link "Register", to: Routes.user_registration_path(@socket, :new) %> |
<%= link "Register", to: Routes.user_registration_path(@socket, :new) %> |
<% end %>
<%= link "Log in", to: Routes.user_session_path(@socket, :new) %> <%= link "Log in", to: Routes.user_session_path(@socket, :new) %>
</p> </p>

View file

@ -1,16 +0,0 @@
<div class="row justify-content-center">
<div class="col-11">
<h2 class="mb-3">
<i class="bi bi-sliders me-1"></i> User Settings
</h2>
<div class="row justify-content-center justify-content-md-start">
<.live_component module={Shift73kWeb.UserLive.Settings.Email} id={"email-#{@current_user.id}"} current_user={@current_user} />
<.live_component module={Shift73kWeb.UserLive.Settings.Password} id={"password-#{@current_user.id}"} current_user={@current_user} />
<.live_component module={Shift73kWeb.UserLive.Settings.WeekStart} id={"week_start-#{@current_user.id}"} current_user={@current_user} />
<.live_component module={Shift73kWeb.UserLive.Settings.CalendarUrl} id={"calendar_url-#{@current_user.id}"} current_user={@current_user} />
</div>
</div>
</div>

View file

@ -0,0 +1,15 @@
<div class="row justify-content-center">
<div class="col-11">
<h2 class="mb-3"><%= icon_div @socket, "bi-sliders", [class: "icon baseline"] %>
User Settings</h2>
<div class="row justify-content-center justify-content-md-start">
<%= live_component @socket, Shift73kWeb.UserLive.Settings.Email, id: "email-#{@current_user.id}", current_user: @current_user %>
<%= live_component @socket, Shift73kWeb.UserLive.Settings.Password, id: "password-#{@current_user.id}", current_user: @current_user %>
<%= live_component @socket, Shift73kWeb.UserLive.Settings.WeekStart, id: "week_start-#{@current_user.id}", current_user: @current_user %>
<%= live_component @socket, Shift73kWeb.UserLive.Settings.CalendarUrl, id: "calendar_url-#{@current_user.id}", current_user: @current_user %>
</div>
</div>
</div>

View file

@ -1,11 +1,11 @@
<div id={@id} class="col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-6 mt-1"> <div id="<%= @id %>" class="col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-6 mt-1">
<h3>iCal Subscribe URL</h3> <h3>iCal Subscribe URL</h3>
<div class="row"> <div class="row">
<div class="col mb-3"> <div class="col mb-3">
<label class="form-label">Use this URL to subscribe in calendar software</label> <label class="form-label">Use this URL to subscribe in calendar software</label>
<input type="text" class="form-control" value={Routes.user_shifts_ics_url(@socket, :index, @current_user.calendar_slug)} readonly onclick="this.focus();this.select()" /> <input type="text" class="form-control" value="<%= Routes.user_shifts_ics_url(@socket, :index, @current_user.calendar_slug) %>" readonly onclick="this.focus();this.select()" />
</div> </div>
</div> </div>

View file

@ -1,12 +1,12 @@
<div id={@id} class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1"> <div id="<%= @id %>" class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<h3>Change email</h3> <h3>Change email</h3>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], fn f -> %> <%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], fn f -> %>
<%= label f, :email, class: "form-label" %> <%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :email)}> <div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :email) %>">
<i class="bi bi-at icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<%= email_input f, :email, <%= email_input f, :email,
value: input_value(f, :email), value: input_value(f, :email),
class: input_class(f, :email, "form-control"), class: input_class(f, :email, "form-control"),
@ -20,7 +20,7 @@
<%= label f, :current_password, class: "form-label" %> <%= label f, :current_password, class: "form-label" %>
<div class="inner-addon left-addon mb-3"> <div class="inner-addon left-addon mb-3">
<i class="bi bi-lock icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-lock", [class: "icon is-left text-muted fs-5"] %>
<%= password_input f, :current_password, <%= password_input f, :current_password,
value: input_value(f, :current_password), value: input_value(f, :current_password),
id: "user_email_current_password", id: "user_email_current_password",

View file

@ -1,12 +1,12 @@
<div id={@id} class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1"> <div id="<%= @id %>" class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<h3>Change password</h3> <h3>Change password</h3>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], fn f -> %> <%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], fn f -> %>
<%= label f, :password, "New password", class: "form-label" %> <%= label f, :password, "New password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}> <div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<i class="bi bi-key icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<%= password_input f, :password, <%= password_input f, :password,
value: input_value(f, :password), value: input_value(f, :password),
class: input_class(f, :password, "form-control"), class: input_class(f, :password, "form-control"),
@ -18,8 +18,8 @@
</div> </div>
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %> <%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password_confirmation)}> <div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password_confirmation) %>">
<i class="bi bi-key-fill icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-key-fill", [class: "icon is-left text-muted fs-5"] %>
<%= password_input f, :password_confirmation, <%= password_input f, :password_confirmation,
value: input_value(f, :password_confirmation), value: input_value(f, :password_confirmation),
class: input_class(f, :password_confirmation, "form-control"), class: input_class(f, :password_confirmation, "form-control"),
@ -31,7 +31,7 @@
<%= label f, :current_password, class: "form-label" %> <%= label f, :current_password, class: "form-label" %>
<div class="inner-addon left-addon mb-3"> <div class="inner-addon left-addon mb-3">
<i class="bi bi-lock icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-lock", [class: "icon is-left text-muted fs-5"] %>
<%= password_input f, :current_password, <%= password_input f, :current_password,
value: input_value(f, :current_password), value: input_value(f, :current_password),
id: "user_password_current_password", id: "user_password_current_password",

View file

@ -1,4 +1,4 @@
<div id={@id} class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1"> <div id="<%= @id %>" class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<h3>Calendar view</h3> <h3>Calendar view</h3>
@ -6,7 +6,7 @@
<%= label cvf, :week_start_at, "Week starts at", class: "form-label" %> <%= label cvf, :week_start_at, "Week starts at", class: "form-label" %>
<div class="inner-addon left-addon mb-3"> <div class="inner-addon left-addon mb-3">
<i class="bi bi-calendar2-range icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-calendar2-range", [class: "icon is-left text-muted fs-5"] %>
<%= select cvf, :week_start_at, week_start_options(), <%= select cvf, :week_start_at, week_start_options(),
value: @current_user.week_start_at, value: @current_user.week_start_at,
class: "form-select" class: "form-select"

View file

@ -9,13 +9,9 @@ defmodule Shift73kWeb.UserManagement.DeleteComponent do
end end
@impl true @impl true
def handle_event("confirm", %{"id" => id, "email" => email} = params, socket) do def handle_event("confirm", %{"id" => id, "email" => email}, socket) do
IO.inspect(params) id
|> Accounts.get_user()
user = Accounts.get_user(id)
IO.inspect(user)
user
|> Accounts.delete_user() |> Accounts.delete_user()
|> case do |> case do
{:ok, _} -> {:ok, _} ->

View file

@ -1,20 +0,0 @@
<div>
<div class="modal-body">
Are you sure you want to delete "<%= @delete_user.email %>"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_user.id,
phx_value_email: @delete_user.email %>
</div>
</div>

View file

@ -0,0 +1,16 @@
<div class="modal-body">
Are you sure you want to delete "<%= @delete_user.email %>"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_user.id,
phx_value_email: @delete_user.email %>
</div>

View file

@ -33,7 +33,7 @@ defmodule Shift73kWeb.UserManagement.FormComponent do
defp save_user(%{assigns: %{action: :new}} = socket, user_params) do defp save_user(%{assigns: %{action: :new}} = socket, user_params) do
case Accounts.register_user(user_params) do case Accounts.register_user(user_params) do
{:ok, user} -> {:ok, user} ->
{:ok, _, %Swoosh.Email{} = _captured_email} = {:ok, %Bamboo.Email{}} =
Accounts.deliver_user_confirmation_instructions( Accounts.deliver_user_confirmation_instructions(
user, user,
&Routes.user_confirmation_url(socket, :confirm, &1) &Routes.user_confirmation_url(socket, :confirm, &1)

View file

@ -1,66 +0,0 @@
<div>
<%= form_for @changeset, "#", [
phx_target: @myself,
phx_change: "validate",
phx_submit: "save"
], fn f -> %>
<div class="modal-body">
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :email)}>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
autofocus: true,
phx_debounce: "250",
aria_describedby: error_ids(f, :email)
%>
<%= error_tag f, :email %>
</div>
<%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}>
<i class="bi bi-key icon is-left text-muted fs-5"></i>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
aria_describedby: error_ids(f, :password)
%>
<%= error_tag f, :password %>
</div>
<%= if Roles.can?(@current_user, %User{}, :edit_role) do %>
<%= label f, :role, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<i class="bi bi-shield icon is-left text-muted fs-5"></i>
<%= select f, :role, Enum.map(User.roles(), fn {k, _v} -> {String.capitalize(Atom.to_string(k)), k} end), class: "form-select" %>
<span class="valid-feedback text-primary" style="display: block;">
<%= role_description(input_value(f, :role)) %>
</span>
</div>
<% else %>
<%= hidden_input f, :role, value: input_value(f, :role) %>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>
</div>

View file

@ -0,0 +1,62 @@
<%= form_for @changeset, "#", [
phx_target: @myself,
phx_change: "validate",
phx_submit: "save"
], fn f -> %>
<div class="modal-body">
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :email) %>">
<%= icon_div @socket, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
autofocus: true,
phx_debounce: "250",
aria_describedby: error_ids(f, :email)
%>
<%= error_tag f, :email %>
</div>
<%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
aria_describedby: error_ids(f, :password)
%>
<%= error_tag f, :password %>
</div>
<%= if Roles.can?(@current_user, %User{}, :edit_role) do %>
<%= label f, :role, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-shield", [class: "icon is-left text-muted fs-5"] %>
<%= select f, :role, Enum.map(User.roles(), fn {k, _v} -> {String.capitalize(Atom.to_string(k)), k} end), class: "form-select" %>
<span class="valid-feedback text-primary" style="display: block;">
<%= role_description(input_value(f, :role)) %>
</span>
</div>
<% else %>
<%= hidden_input f, :role, value: input_value(f, :role) %>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>

View file

@ -198,7 +198,7 @@ defmodule Shift73kWeb.UserManagementLive.Index do
def dt_out(ndt) do def dt_out(ndt) do
ndt ndt
|> DateTime.from_naive!(Shift73k.get_app_time_zone()) |> DateTime.from_naive!(Shift73k.app_time_zone())
|> Calendar.strftime("%Y %b %-d, %-I:%M %p") |> Calendar.strftime("%Y %b %-d, %-I:%M %p")
end end
end end

View file

@ -1,34 +1,24 @@
<%= if @live_action in [:new, :edit] do %> <%= if @live_action in [:new, :edit] do %>
<.live_component <%= live_modal @socket, Shift73kWeb.UserManagement.FormComponent,
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.UserManagement.FormComponent}
opts={[
id: @user.id || :new, id: @user.id || :new,
title: @page_title, title: @page_title,
action: @live_action, action: @live_action,
user: @user, user: @user,
current_user: @current_user current_user: @current_user %>
]}
/>
<% end %> <% end %>
<%= if @delete_user do %> <%= if @delete_user do %>
<.live_component <%= live_modal @socket, Shift73kWeb.UserManagement.DeleteComponent,
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.UserManagement.DeleteComponent}
opts={[
id: @delete_user.id, id: @delete_user.id,
title: "Delete User", title: "Delete User",
delete_user: @delete_user delete_user: @delete_user
]} %>
/>
<% end %> <% end %>
<h2 class="mb-3"> <h2 class="mb-3">
<i class="bi bi-people me-1"></i> Listing Users <%= icon_div @socket, "bi-people", [class: "icon baseline"] %>
Listing Users
</h2> </h2>
<%# filtering and new item creation %> <%# filtering and new item creation %>
@ -37,7 +27,8 @@
<div class="col-12 col-md-6 col-lg-4 col-xl-3"> <div class="col-12 col-md-6 col-lg-4 col-xl-3">
<%= live_patch to: Routes.user_management_index_path(@socket, :new, Enum.into(@query, [])), <%= live_patch to: Routes.user_management_index_path(@socket, :new, Enum.into(@query, [])),
class: "btn btn-primary mb-3 mb-md-0" do %> class: "btn btn-primary mb-3 mb-md-0" do %>
<i class="bi bi-person-plus me-1"></i> New User <%= icon_div @socket, "bi-person-plus", [class: "icon baseline me-1"] %>
New User
<% end %> <% end %>
</div> </div>
@ -45,10 +36,10 @@
<%= form_for :sort, "#", [phx_change: "sort-by-change"], fn srt -> %> <%= form_for :sort, "#", [phx_change: "sort-by-change"], fn srt -> %>
<%= label srt, :sort_by, class: "visually-hidden" %> <%= label srt, :sort_by, class: "visually-hidden" %>
<div class="input-group inner-addon left-addon mb-3 mb-md-0"> <div class="input-group inner-addon left-addon mb-3 mb-md-0">
<i class="bi bi-arrow-down-up icon is-left text-muted fs-5" style="z-index:1001;"></i> <%= icon_div @socket, "bi-arrow-down-up", [class: "icon is-left text-muted fs-5", style: "z-index:1001;"], [style: "padding: 1px;"] %>
<%= Phoenix.HTML.Form.select srt, :sort_by, ["Email": "email", "Role": "role", "Created at": "inserted_at"], value: @query.sort_by, class: "form-select rounded-start" %> <%= Phoenix.HTML.Form.select srt, :sort_by, ["Email": "email", "Role": "role", "Created at": "inserted_at"], value: @query.sort_by, class: "form-select" %>
<button class="btn btn-primary" type="button" aria-label="Change sort order" phx-click="sort-order-change"> <button class="btn btn-primary" type="button" aria-label="Change sort order" phx-click="sort-order-change">
<i class={if @query.sort_order == "desc", do: "bi bi-sort-up-alt", else: "bi bi-sort-down-alt"}></i> <%= icon_div @socket, (@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"), [class: "icon baseline"] %>
</button> </button>
</div> </div>
<% end %> <% end %>
@ -58,9 +49,9 @@
<%= form_for :filter, "#", [phx_change: "filter-change"], fn flt -> %> <%= form_for :filter, "#", [phx_change: "filter-change"], fn flt -> %>
<%= label flt, :filter, class: "visually-hidden" %> <%= label flt, :filter, class: "visually-hidden" %>
<div class="inner-addon left-addon right-addon"> <div class="inner-addon left-addon right-addon">
<i class="bi bi-funnel icon is-left text-muted fs-5"></i> <%= icon_div @socket, "bi-funnel", [class: "icon is-left text-muted fs-5"] %>
<%= if @query.filter != "" do %> <%= if @query.filter != "" do %>
<i class="bi bi-x-circle-fill icon is-right text-primary fs-5 cursor-pointer pe-auto" role="img" aria-hidden="false" aria-label="Clear filter" phx-click="filter-clear"></i> <%= icon_div @socket, "bi-x-circle-fill", [class: "icon is-right text-primary fs-5"], [role: "img", aria_hidden: false, aria_label: "Clear filter", class: "cursor-pointer pe-auto", phx_click: "filter-clear"] %>
<% end %> <% end %>
<%= text_input flt, :filter, <%= text_input flt, :filter,
name: "filter", name: "filter",
@ -101,19 +92,21 @@
</dt> </dt>
<dd class="d-inline d-sm-block col-auto"> <dd class="d-inline d-sm-block col-auto">
<span class="visually-hidden"><%= user.confirmed_at && "Yes" || "No" %></span> <span class="visually-hidden"><%= user.confirmed_at && "Yes" || "No" %></span>
<input type="checkbox" class="form-check-input" aria-hidden="true" checked={user.confirmed_at && :true || :false} disabled> <input type="checkbox" class="form-check-input" aria-hidden="true" <%= user.confirmed_at && "checked" || "" %> disabled>
</dd> </dd>
</dl> </dl>
<%= if Roles.can?(@current_user, user, :edit) do %> <%= if Roles.can?(@current_user, user, :edit) do %>
<%= live_patch to: Routes.user_management_index_path(@socket, :edit, user.id, Enum.into(@query, [])), class: "btn btn-primary btn-sm text-nowrap" do %> <%= live_patch to: Routes.user_management_index_path(@socket, :edit, user.id, Enum.into(@query, [])), class: "btn btn-primary btn-sm text-nowrap" do %>
<i class="bi bi-pencil me-1"></i> Edit <%= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
Edit
<% end %> <% end %>
<% end %> <% end %>
<%= if Roles.can?(@current_user, user, :delete) do %> <%= if Roles.can?(@current_user, user, :delete) do %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={user.id}> <button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id="<%= user.id %>">
<i class="bi bi-trash me-1"></i> Delete <%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
</button> </button>
<% end %> <% end %>
@ -133,25 +126,28 @@
<thead> <thead>
<tr> <tr>
<th scope="col" style="white-space: nowrap;" phx-click="sort-change" phx-value-sort_by="email" class="cursor-pointer"> <th scope="col" phx-click="sort-change" phx-value-sort_by="email" class="cursor-pointer">
Email Email
<%= if @query.sort_by == "email" do %> <%= if @query.sort_by == "email", do: icon_div @socket,
<i class={@query.sort_order == "desc" && "bi bi-sort-up-alt ms-1" || "bi bi-sort-down-alt ms-1"}></i> (@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
<% end %> [class: "icon baseline ms-1"]
%>
</th> </th>
<th scope="col" style="white-space: nowrap;" phx-click="sort-change" phx-value-sort_by="role" class="cursor-pointer"> <th scope="col" phx-click="sort-change" phx-value-sort_by="role" class="cursor-pointer">
Role Role
<%= if @query.sort_by == "role" do %> <%= if @query.sort_by == "role", do: icon_div @socket,
<i class={@query.sort_order == "desc" && "bi bi-sort-up-alt ms-1" || "bi bi-sort-down-alt ms-1"}></i> (@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
<% end %> [class: "icon baseline ms-1"]
%>
</th> </th>
<th scope="col" style="white-space: nowrap;" phx-click="sort-change" phx-value-sort_by="inserted_at" class="cursor-pointer"> <th scope="col" phx-click="sort-change" phx-value-sort_by="inserted_at" class="cursor-pointer">
Created at Created at
<%= if @query.sort_by == "inserted_at" do %> <%= if @query.sort_by == "inserted_at", do: icon_div @socket,
<i class={@query.sort_order == "desc" && "bi bi-sort-up-alt ms-1" || "bi bi-sort-down-alt ms-1"}></i> (@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
<% end %> [class: "icon baseline ms-1"]
%>
</th> </th>
<th scope="col">Confirmed?</th> <th scope="col">Confirmed?</th>
@ -172,25 +168,27 @@
</tr> </tr>
<% else %> <% else %>
<%= for user <- @page.entries do %> <%= for user <- @page.entries do %>
<tr id={"user-#{user.id}"}> <tr id="user-<%= user.id %>">
<td class="align-middle"><%= user.email %></td> <td class="align-middle"><%= user.email %></td>
<td class="align-middle"><%= user.role |> Atom.to_string() |> String.capitalize() %></td> <td class="align-middle"><%= user.role |> Atom.to_string() |> String.capitalize() %></td>
<td class="align-middle" style="white-space: nowrap;"><%= dt_out(user.inserted_at) %></td> <td class="align-middle" style="white-space: nowrap;"><%= dt_out(user.inserted_at) %></td>
<td class="align-middle"> <td class="align-middle">
<span class="visually-hidden"><%= user.confirmed_at && "Confirmed" || "Not confirmed" %></span> <span class="visually-hidden"><%= user.confirmed_at && "Confirmed" || "Not confirmed" %></span>
<input type="checkbox" class="form-check-input" aria-hidden="true" checked={user.confirmed_at && :true || :false} disabled> <input type="checkbox" class="form-check-input" aria-hidden="true" <%= user.confirmed_at && "checked" || "" %> disabled>
</td> </td>
<td class="align-middle text-end text-nowrap"> <td class="align-middle text-end text-nowrap">
<%= if Roles.can?(@current_user, user, :edit) do %> <%= if Roles.can?(@current_user, user, :edit) do %>
<%= live_patch to: Routes.user_management_index_path(@socket, :edit, user.id, Enum.into(@query, [])), class: "btn btn-outline-primary btn-sm text-nowrap" do %> <%= live_patch to: Routes.user_management_index_path(@socket, :edit, user.id, Enum.into(@query, [])), class: "btn btn-outline-primary btn-sm text-nowrap" do %>
<i class="bi bi-pencil me-1"></i> Edit <%= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
Edit
<% end %> <% end %>
<% end %> <% end %>
<%= if Roles.can?(@current_user, user, :delete) do %> <%= if Roles.can?(@current_user, user, :delete) do %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={user.id}> <button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id="<%= user.id %>">
<i class="bi bi-trash me-1"></i> Delete <%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
</button> </button>
<% end %> <% end %>
@ -229,47 +227,47 @@
<ul class="pagination mb-0"> <ul class="pagination mb-0">
<%# previous page button %> <%# previous page button %>
<% icon = icon_div @socket, "bi-chevron-left", [class: "icon baseline"] %>
<%= if @page.page_number == 1 do %> <%= if @page.page_number == 1 do %>
<li class="page-item disabled"> <li class="page-item disabled">
<span class="page-link" aria-hidden="true"><i class="bi bi-chevron-left"></i></span> <span class="page-link" aria-hidden="true"><%= icon %></span>
<span class="visually-hidden">Previous</span> <span class="visually-hidden">Previous</span>
</li>
<% else %> <% else %>
<li class="page-item"> <li class="page-item">
<a class="page-link" href="#" aria-label="Previous" phx-value-page_number={@page.page_number - 1} phx-click="page-change"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="#" aria-label="Previous" phx-value-page_number="<%= @page.page_number - 1 %>" phx-click="page-change"><%= icon %></a>
</li>
<% end %> <% end %>
</li>
<%# page buttons %> <%# page buttons %>
<%= for page_num <- generate_page_list(@page.page_number, @page.total_pages) do %> <%= for page_num <- generate_page_list(@page.page_number, @page.total_pages) do %>
<%= cond do %> <%= cond do %>
<% page_num < 1 -> %> <%= page_num < 1 -> %>
<li class="page-item disabled"> <li class="page-item disabled">
<span class="page-link" aria-hidden="true">&hellip;</span> <span class="page-link" aria-hidden="true">&hellip;</span>
<span class="visually-hidden" role="img" aria-label="ellipses">&hellip;</span> <span class="visually-hidden" role="img" aria-label="ellipses">&hellip;</span>
</li> </li>
<% page_num == @page.page_number -> %> <% page_num == @page.page_number -> %>
<li class="page-item active" aria-current="page"> <li class="page-item active" aria-current="page">
<span class="page-link"><%= page_num %></span> <span class="page-link"><%= page_num %></a>
</li> </li>
<% true -> %> <% true -> %>
<li class="page-item"> <li class="page-item">
<a class="page-link" href="#" phx-value-page_number={page_num} phx-click="page-change"><%= page_num %></a> <a class="page-link" href="#" phx-value-page_number="<%= page_num %>" phx-click="page-change"><%= page_num %></a>
</li> </li>
<% end %> <% end %>
<% end %> <% end %>
<%# next page button %> <%# next page button %>
<% icon = icon_div @socket, "bi-chevron-right", [class: "icon baseline"] %>
<%= if @page.page_number == @page.total_pages do %> <%= if @page.page_number == @page.total_pages do %>
<li class="page-item disabled"> <li class="page-item disabled">
<span class="page-link" aria-hidden="true"><i class="bi bi-chevron-right"></i></span> <span class="page-link" aria-hidden="true"><%= icon %></span>
<span class="visually-hidden">Next</span> <span class="visually-hidden">Next</span>
</li>
<% else %> <% else %>
<li class="page-item"> <li class="page-item">
<a class="page-link" href="#" aria-label="Next" phx-value-page_number={@page.page_number + 1} phx-click="page-change"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="#" aria-label="Next" phx-value-page_number="<%= @page.page_number + 1 %>" phx-click="page-change"><%= icon %></a>
</li>
<% end %> <% end %>
</li>
</ul> </ul>
</nav> </nav>

View file

@ -1,34 +0,0 @@
defmodule Shift73kWeb.EnsureAllowRegistrationPlug do
@moduledoc """
This plug ensures that there is at least one known User.
"""
import Plug.Conn
import Phoenix.Controller
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Repo
alias Shift73k.Accounts.User
@doc false
@spec init(any()) :: any()
def init(config), do: config
@doc false
@spec call(Conn.t(), atom() | [atom()]) :: Conn.t()
def call(conn, _opts) do
# If there aren't even any users, or registration is allowed
if !Repo.exists?(User) || get_app_allow_reg() do
# We will allow registration
conn
else
# Otherwise,
# if app is configured to not allow registration,
# and there is a user,
# then we redirect to root URL
conn
|> redirect(to: "/")
|> halt()
end
end
end

View file

@ -27,7 +27,8 @@ defmodule Shift73kWeb.EnsureRolePlug do
def call(conn, roles) do def call(conn, roles) do
user_token = get_session(conn, :user_token) user_token = get_session(conn, :user_token)
(user_token && Accounts.get_user_by_session_token(user_token)) (user_token &&
Accounts.get_user_by_session_token(user_token))
|> has_role?(roles) |> has_role?(roles)
|> maybe_halt(conn) |> maybe_halt(conn)
end end

View file

@ -1,30 +0,0 @@
defmodule Shift73kWeb.EnsureUserExistPlug do
@moduledoc """
This plug ensures that there is at least one known User.
"""
import Plug.Conn
import Phoenix.Controller
alias Shift73k.Repo
alias Shift73k.Accounts.User
alias Shift73kWeb.Router.Helpers, as: Routes
@doc false
@spec init(any()) :: any()
def init(config), do: config
@doc false
@spec call(Conn.t(), atom() | [atom()]) :: Conn.t()
def call(conn, _opts) do
# If there aren't even any users,
if !Repo.exists?(User) do
# We're just going to redirect to registration
conn
|> redirect(to: Routes.user_registration_path(conn, :new))
|> halt()
else
# Otherwise we proceed as normal
conn
end
end
end

View file

@ -2,100 +2,98 @@ defmodule Shift73kWeb.Router do
use Shift73kWeb, :router use Shift73kWeb, :router
import Shift73kWeb.UserAuth import Shift73kWeb.UserAuth
alias Shift73kWeb.EnsureRolePlug alias Shift73kWeb.EnsureRolePlug
alias Shift73kWeb.EnsureUserExistPlug
alias Shift73kWeb.EnsureAllowRegistrationPlug
pipeline :browser do pipeline :browser do
plug :accepts, ["html"] plug(:accepts, ["html"])
plug :fetch_session plug(:fetch_session)
plug :fetch_live_flash plug(:fetch_live_flash)
plug :put_root_layout, {Shift73kWeb.LayoutView, :root} plug(:put_root_layout, {Shift73kWeb.LayoutView, :root})
plug :protect_from_forgery plug(:protect_from_forgery)
plug :put_secure_browser_headers plug(:put_secure_browser_headers)
plug :fetch_current_user plug(:fetch_current_user)
end end
pipeline :ensure_role_user do pipeline :api do
plug EnsureRolePlug, [:admin, :manager, :user] plug(:accepts, ["json"])
end end
pipeline :ensure_user_exist do pipeline :user do
plug EnsureUserExistPlug plug(EnsureRolePlug, [:admin, :manager, :user])
end end
pipeline :ensure_allow_registration do pipeline :manager do
plug EnsureAllowRegistrationPlug plug(EnsureRolePlug, [:admin, :manager])
end end
pipeline :ensure_role_manager do pipeline :admin do
plug EnsureRolePlug, [:admin, :manager] plug(EnsureRolePlug, :admin)
end end
pipeline :ensure_role_admin do scope "/", Shift73kWeb do
plug EnsureRolePlug, :admin pipe_through([:browser])
get("/", Redirector, to: "/assign")
end end
# Enables the Swoosh mailbox preview in development. # Other scopes may use custom stacks.
# scope "/api", Shift73kWeb do
# pipe_through :api
# end
# Enables LiveDashboard only for development
# #
# Note that preview only shows emails that were sent by the same # If you want to use the LiveDashboard in production, you should put
# node running the Phoenix server. # it behind authentication and allow only admins to access it.
if Mix.env() == :dev do # If your application does not have an admins-only section yet,
scope "/dev" do # you can use Plug.BasicAuth to set up some basic authentication
pipe_through :browser # as long as you are also using SSL (which you should anyway).
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router
forward "/mailbox", Plug.Swoosh.MailboxPreview scope "/" do
pipe_through(:browser)
live_dashboard("/dashboard", metrics: Shift73kWeb.Telemetry)
end end
end end
scope "/", Shift73kWeb do scope "/", Shift73kWeb do
pipe_through([:browser, :ensure_user_exist]) pipe_through([:browser, :redirect_if_user_is_authenticated])
get "/", Redirector, to: "/assign"
end
scope "/", Shift73kWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated, :ensure_allow_registration]
get "/users/register", UserRegistrationController, :new
end
scope "/", Shift73kWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated, :ensure_user_exist]
# session routes, irrelevant if user is authenticated # session routes, irrelevant if user is authenticated
get "/users/log_in", UserSessionController, :new get("/users/register", UserRegistrationController, :new)
post "/users/log_in", UserSessionController, :create get("/users/log_in", UserSessionController, :new)
get "/users/reset_password", UserResetPasswordController, :new post("/users/log_in", UserSessionController, :create)
post "/users/reset_password", UserResetPasswordController, :create get("/users/reset_password", UserResetPasswordController, :new)
get "/users/reset_password/:token", UserResetPasswordController, :edit post("/users/reset_password", UserResetPasswordController, :create)
get("/users/reset_password/:token", UserResetPasswordController, :edit)
end end
scope "/", Shift73kWeb do scope "/", Shift73kWeb do
pipe_through [:browser, :require_authenticated_user] pipe_through([:browser, :require_authenticated_user])
# user settings (change email, password, calendar week start, etc) # user settings (change email, password, calendar week start, etc)
live "/users/settings", UserLive.Settings, :edit live("/users/settings", UserLive.Settings, :edit)
# confirm email by token # confirm email by token
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email get("/users/settings/confirm_email/:token", UserSettingsController, :confirm_email)
end end
scope "/", Shift73kWeb do scope "/", Shift73kWeb do
pipe_through [:browser, :ensure_user_exist] pipe_through([:browser])
# session paths # session paths
delete "/users/log_out", UserSessionController, :delete delete("/users/log_out", UserSessionController, :delete)
get "/users/force_logout", UserSessionController, :force_logout get("/users/force_logout", UserSessionController, :force_logout)
get "/users/confirm", UserConfirmationController, :new get("/users/confirm", UserConfirmationController, :new)
post "/users/confirm", UserConfirmationController, :create post("/users/confirm", UserConfirmationController, :create)
get "/users/confirm/:token", UserConfirmationController, :confirm get("/users/confirm/:token", UserConfirmationController, :confirm)
# ics/ical route for user's shifts # ics/ical route for user's shifts
get "/ics/:slug", UserShiftsIcsController, :index get("/ics/:slug", UserShiftsIcsController, :index)
end end
scope "/", Shift73kWeb do scope "/", Shift73kWeb do
pipe_through [:browser, :require_authenticated_user, :ensure_role_user] pipe_through([:browser, :require_authenticated_user, :user])
live "/templates", ShiftTemplateLive.Index, :index live "/templates", ShiftTemplateLive.Index, :index
live "/templates/new", ShiftTemplateLive.Index, :new live "/templates/new", ShiftTemplateLive.Index, :new
@ -113,16 +111,16 @@ defmodule Shift73kWeb.Router do
end end
# scope "/", Shift73kWeb do # scope "/", Shift73kWeb do
# pipe_through([:browser, :require_authenticated_user, :ensure_role_admin]) # pipe_through([:browser, :require_authenticated_user, :admin])
# end # end
# Users Management # Users Management
scope "/users", Shift73kWeb do scope "/users", Shift73kWeb do
pipe_through [:browser, :require_authenticated_user, :ensure_role_manager, :require_email_confirmed] pipe_through([:browser, :require_authenticated_user, :manager, :require_email_confirmed])
live "/", UserManagementLive.Index, :index live("/", UserManagementLive.Index, :index)
live "/new", UserManagementLive.Index, :new live("/new", UserManagementLive.Index, :new)
live "/edit/:id", UserManagementLive.Index, :edit live("/edit/:id", UserManagementLive.Index, :edit)
# resources "/", UserManagementController, only: [:new, :create, :edit, :update] # resources "/", UserManagementController, only: [:new, :create, :edit, :update]
end end
end end

View file

@ -0,0 +1,88 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-4">
<div class="container">
<h1 class="fs-4 my-0 py-0 lh-base">
<%= link to: "/", class: "navbar-brand fs-4" do %>
<%= icon_div @conn, "bi-calendar2-week", [class: "icon baseline me-1"] %>
<span class="fw-light">Shift73k</span>
<% end %>
</h1>
<%= if @current_user do %>
<button class="hamburger hamburger--squeeze collapsed navbar-toggler" id="navbarSupportedContentToggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="hamburger-box d-flex">
<span class="hamburger-inner"></span>
</span>
</button>
<% else %>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "btn btn-outline-light d-block d-lg-none") do %>
<%= icon_div @conn, "bi-door-open", [class: "icon baseline"] %>
Log in
<% end %>
<% end %>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<%# nav LEFT items %>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<%#= if @current_user do %>
<%# <li class="nav-item"> %>
<%#= link nav_link_opts(@conn, to: Routes.shift_template_index_path(@conn, :index), class: "nav-link") do %>
<%#= icon_div @conn, "bi-clock-history", [class: "icon baseline me-1"] %>
<%# Templates %>
<%# end %>
<%# </li> %>
<%# end %>
<%# normal navbar link example %>
<%# <li class="nav-item"> %>
<%#= link "Properties", nav_link_opts(@conn, to: Routes.property_index_path(@conn, :index), class: "nav-link") %>
<%# </li> %>
<%# ACTIVE page link example %>
<%# <li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li> %>
<%# DISABLED page link example %>
<%# <li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
</li> %>
<%# normal dropdown menu example %>
<%# <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownExample" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdownExample">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</li> %>
</ul>
<%# nav RIGHT items %>
<ul class="navbar-nav">
<%= if @current_user do %>
<%= render "navbar/_shifts_menu.html", assigns %>
<%= render "navbar/_user_menu.html", assigns %>
<% else %>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "btn btn-outline-light d-none d-lg-block") do %>
<%= icon_div @conn, "bi-door-open", [class: "icon baseline"] %>
Log in
<% end %>
<% end %>
</ul>
</div>
</div>
</nav>

View file

@ -1,70 +0,0 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-4">
<div class="container">
<h1 class="fs-4 my-0 py-0 lh-base">
<%= link to: "/", class: "navbar-brand fs-4" do %>
<i class="bi bi-calendar2-week me-1"></i>
<span class="fw-light">Shift73k</span>
<% end %>
</h1>
<%# If there's a current user,
OR if there are users & we allow registration,
THEN we will show a full menu configuration %>
<%= if @current_user || (Repo.exists?(User) && allow_registration()) do %>
<button class="hamburger hamburger--squeeze collapsed navbar-toggler" id="navbarSupportedContentToggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="hamburger-box d-flex">
<span class="hamburger-inner"></span>
</span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<%# nav LEFT items %>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
</ul>
<%# nav RIGHT items %>
<ul class="navbar-nav">
<%= if @current_user do %>
<%= render "navbar/_shifts_menu.html", assigns %>
<%= render "navbar/_user_menu.html", assigns %>
<% else %>
<%= render "navbar/_nouser_menu.html", assigns %>
<% end %>
</ul>
</div>
<%# If there's no current user,
AND:
There are no users -- [REGISTER]
OR no registration allowed -- [LOG IN] %>
<% else %>
<%= if !Repo.exists?(User) || allow_registration() do %>
<%= link nav_link_opts(@conn, to: Routes.user_registration_path(@conn, :new), class: "btn btn-outline-light") do %>
<i class="bi bi-journal-plus"></i> Register
<% end %>
<% else %>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "btn btn-outline-light") do %>
<i class="bi bi-door-open"></i> Log in
<% end %>
<% end %>
<% end %>
</div>
</nav>

View file

@ -1,10 +0,0 @@
<%= if dev_env?() do %>
<script type="module" src="http://localhost:3000/@vite/client"></script>
<script type="module" src="http://localhost:3000/js/app.js"></script>
<% else %>
<link rel="preload" href={Routes.static_path(@conn, "/assets/lato-latin-300-normal.woff2")} as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href={Routes.static_path(@conn, "/assets/lato-latin-400-normal.woff2")} as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href={Routes.static_path(@conn, "/assets/lato-latin-700-normal.woff2")} as="font" type="font/woff2" crossorigin="anonymous">
<link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
<script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
<% end %>

View file

@ -5,7 +5,7 @@
<div class="col-md-12 col-lg-10 col-xxl-8 "> <div class="col-md-12 col-lg-10 col-xxl-8 ">
<%= for {kind, class} <- alert_kinds() do %> <%= for {kind, class} <- alert_kinds() do %>
<%= if flash_content = get_flash(@conn, kind) do %> <%= if flash_content = get_flash(@conn, kind) do %>
<div class={"alert #{class} alert-dismissible fade show"} role="alert"> <div class="alert <%= class %> alert-dismissible fade show" role="alert">
<%= flash_content %> <%= flash_content %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>

View file

@ -5,7 +5,7 @@
<div class="col-md-12 col-lg-10 col-xxl-8 "> <div class="col-md-12 col-lg-10 col-xxl-8 ">
<%= for {kind, class} <- alert_kinds() do %> <%= for {kind, class} <- alert_kinds() do %>
<%= if flash_content = live_flash(@flash, kind) do %> <%= if flash_content = live_flash(@flash, kind) do %>
<div class={"alert #{class} alert-dismissible fade show"} role="alert" id={"lv-alert-#{kind}"} phx-hook="AlertRemover" data-key={"#{kind}"}> <div class="alert <%= class %> alert-dismissible fade show" role="alert" id="lv-alert-<%= kind %>" phx-hook="AlertRemover" data-key="<%= kind %>">
<%= flash_content %> <%= flash_content %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>

View file

@ -1,23 +0,0 @@
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownNoUserMenu" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle me-1"></i> Hello?
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownNoUserMenu">
<li>
<%= link nav_link_opts(@conn, to: Routes.user_registration_path(@conn, :new), class: "dropdown-item") do %>
<i class="bi bi-journal-plus me-1"></i> Register
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "dropdown-item") do %>
<i class="bi bi-door-open me-1"></i> Log in
<% end %>
</li>
</ul>
</li>

View file

@ -1,24 +1,28 @@
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownShiftsMenu" data-bs-toggle="dropdown" aria-expanded="false"> <a href="#" class="nav-link dropdown-toggle" id="navbarDropdownUserMenu" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-calendar2 me-1"></i> Shifts <%= icon_div @conn, "bi-calendar2", [class: "icon baseline me-1"] %>
Shifts
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownShiftsMenu"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownUserMenu">
<li> <li>
<%= link nav_link_opts(@conn, to: Routes.shift_assign_index_path(@conn, :index), class: "dropdown-item") do %> <%= link nav_link_opts(@conn, to: Routes.shift_assign_index_path(@conn, :index), class: "dropdown-item") do %>
<i class="bi bi-calendar2-plus me-1"></i> Schedule Shifts <%= icon_div @conn, "bi-calendar2-plus", [class: "icon baseline me-1"] %>
<% end %> Schedule Shifts
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.shift_template_index_path(@conn, :index), class: "dropdown-item") do %>
<i class="bi bi-clock-history me-1"></i> My Shift Templates
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link nav_link_opts(@conn, to: Routes.shift_index_path(@conn, :index), class: "dropdown-item") do %> <%= link nav_link_opts(@conn, to: Routes.shift_index_path(@conn, :index), class: "dropdown-item") do %>
<i class="bi bi-card-list me-1"></i> My Scheduled Shifts <%= icon_div @conn, "bi-card-list", [class: "icon baseline me-1"] %>
My Scheduled Shifts
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.shift_template_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-clock-history", [class: "icon baseline me-1"] %>
My Shift Templates
<% end %> <% end %>
</li> </li>
@ -26,12 +30,14 @@
<%# user_shifts_csv_path %> <%# user_shifts_csv_path %>
<li> <li>
<%= link nav_link_opts(@conn, to: Routes.user_shifts_csv_path(@conn, :new), class: "dropdown-item") do %> <%= link nav_link_opts(@conn, to: Routes.user_shifts_csv_path(@conn, :new), class: "dropdown-item") do %>
<i class="bi bi-file-earmark-spreadsheet me-1"></i> CSV Export <%= icon_div @conn, "bi-file-earmark-spreadsheet", [class: "icon baseline me-1"] %>
CSV Export
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link nav_link_opts(@conn, to: Routes.shift_import_index_path(@conn, :index), class: "dropdown-item") do %> <%= link nav_link_opts(@conn, to: Routes.shift_import_index_path(@conn, :index), class: "dropdown-item") do %>
<i class="bi bi-box-arrow-in-left me-1"></i> iCal Import <%= icon_div @conn, "bi-box-arrow-in-left", [class: "icon baseline me-1"] %>
iCal Import
<% end %> <% end %>
</li> </li>

View file

@ -1,7 +1,8 @@
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownUserMenu" data-bs-toggle="dropdown" aria-expanded="false"> <a href="#" class="nav-link dropdown-toggle" id="navbarDropdownUserMenu" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle me-1"></i> Hello! <%= icon_div @conn, "bi-person-circle", [class: "icon baseline me-1"] %>
<%= @current_user && "Hello!" || "Hello?" %>
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownUserMenu"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownUserMenu">
@ -11,19 +12,22 @@
<%= if Roles.can?(@current_user, %User{}, :index) do %> <%= if Roles.can?(@current_user, %User{}, :index) do %>
<li> <li>
<%= link nav_link_opts(@conn, to: Routes.user_management_index_path(@conn, :index), class: "dropdown-item") do %> <%= link nav_link_opts(@conn, to: Routes.user_management_index_path(@conn, :index), class: "dropdown-item") do %>
<i class="bi bi-people me-1"></i> Users <%= icon_div @conn, "bi-people", [class: "icon baseline me-1"] %>
Users
<% end %> <% end %>
</li> </li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<% end %> <% end %>
<li> <li>
<%= link nav_link_opts(@conn, to: Routes.user_settings_path(@conn, :edit), class: "dropdown-item") do %> <%= link nav_link_opts(@conn, to: Routes.user_settings_path(@conn, :edit), class: "dropdown-item") do %>
<i class="bi bi-sliders me-1"></i> Settings <%= icon_div @conn, "bi-sliders", [class: "icon baseline me-1"] %>
Settings
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :delete), method: :delete, class: "dropdown-item") do %> <%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :delete), method: :delete, class: "dropdown-item") do %>
<i class="bi bi-door-closed me-1"></i> Log out <%= icon_div @conn, "bi-door-closed", [class: "icon baseline me-1"] %>
Log out
<% end %> <% end %>
</li> </li>

View file

@ -1,30 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<Phoenix.Component.live_title suffix=" · Shift73k">
<%= assigns[:page_title] || "Hi!" %>
</Phoenix.Component.live_title>
<%= render "_preamble.html", assigns %>
<link rel="apple-touch-icon" sizes="180x180" href={Routes.static_path(@conn, "/apple-touch-icon.png")}>
<link rel="icon" type="image/png" sizes="32x32" href={Routes.static_path(@conn, "/favicon-32x32.png")}>
<link rel="icon" type="image/png" sizes="16x16" href={Routes.static_path(@conn, "/favicon-16x16.png")}>
<link rel="manifest" href={Routes.static_path(@conn, "/site.webmanifest")}>
<link rel="mask-icon" href={Routes.static_path(@conn, "/safari-pinned-tab.svg")} color="#78868a">
<meta name="apple-mobile-web-app-title" content="Shift73k">
<meta name="application-name" content="Shift73k">
<meta name="msapplication-TileColor" content="#ee6c4d">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="favicon.ico">
</head>
<body>
<%= render "_navbar.html", assigns %>
<%= @inner_content %>
</body>
</html>

View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "", suffix: assigns[:page_title] && " · Shift73k" || "Shift73k" %>
<link rel="preload" href="<%= Routes.static_path(@conn, "/fonts/lato-latin-300-normal.woff2") %>" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="<%= Routes.static_path(@conn, "/fonts/lato-latin-400-normal.woff2") %>" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="<%= Routes.static_path(@conn, "/fonts/lato-latin-700-normal.woff2") %>" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="apple-touch-icon" sizes="180x180" href="<%= Routes.static_path(@conn, "/apple-touch-icon.png") %>">
<link rel="icon" type="image/png" sizes="32x32" href="<%= Routes.static_path(@conn, "/favicon-32x32.png") %>">
<link rel="icon" type="image/png" sizes="16x16" href="<%= Routes.static_path(@conn, "/favicon-16x16.png") %>">
<link rel="manifest" href="<%= Routes.static_path(@conn, "/site.webmanifest") %>">
<link rel="mask-icon" href="<%= Routes.static_path(@conn, "/safari-pinned-tab.svg") %>" color="#78868a">
<meta name="apple-mobile-web-app-title" content="Shift73k">
<meta name="application-name" content="Shift73k">
<meta name="msapplication-TileColor" content="#ee6c4d">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="favicon.ico">
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head>
<body>
<%= render "_navbar.html", assigns %>
<%= @inner_content %>
</body>
</html>

View file

@ -2,7 +2,8 @@
<div class="col-11 col-sm-10 col-md-8 col-lg-7 col-xl-6 col-xxl-5"> <div class="col-11 col-sm-10 col-md-8 col-lg-7 col-xl-6 col-xxl-5">
<h2> <h2>
<i class="bi bi-arrow-repeat me-1"></i> Resend confirmation instructions <%= icon_div @conn, "bi-arrow-repeat", [class: "icon baseline"] %>
Resend confirmation instructions
</h2> </h2>
<p class="lead">We'll send you another email with instructions to confirm your email address.</p> <p class="lead">We'll send you another email with instructions to confirm your email address.</p>
@ -10,7 +11,7 @@
<%= label f, :email, class: "form-label" %> <%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3"> <div class="inner-addon left-addon mb-3">
<i class="bi bi-at icon is-left text-muted fs-5"></i> <%= icon_div @conn, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<%= email_input f, :email, <%= email_input f, :email,
value: @current_user && @current_user.email || "", value: @current_user && @current_user.email || "",
placeholder: "e.g., babka@73k.us", placeholder: "e.g., babka@73k.us",
@ -29,9 +30,6 @@
<% end %> <% end %>
<p> <p>
<%= if allow_registration() do %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<% end %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %> <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
</p> </p>

View file

@ -2,7 +2,8 @@
<div class="col-11 col-sm-10 col-md-8 col-lg-7 col-xl-6 col-xxl-5"> <div class="col-11 col-sm-10 col-md-8 col-lg-7 col-xl-6 col-xxl-5">
<h2> <h2>
<i class="bi bi-patch-question me-1"></i> Forgot your password? <%= icon_div @conn, "mdi-head-question-outline", [class: "icon baseline"] %>
Forgot your password?
</h2> </h2>
<p class="lead">We'll send you an email with instructions to reset your password.</p> <p class="lead">We'll send you an email with instructions to reset your password.</p>
@ -10,7 +11,7 @@
<%= label f, :email, class: "form-label" %> <%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3"> <div class="inner-addon left-addon mb-3">
<i class="bi bi-at icon is-left text-muted fs-5"></i> <%= icon_div @conn, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<%= email_input f, :email, <%= email_input f, :email,
placeholder: "e.g., babka@73k.us", placeholder: "e.g., babka@73k.us",
class: "form-control", class: "form-control",
@ -27,9 +28,7 @@
<% end %> <% end %>
<p> <p>
<%= if allow_registration() do %> <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<% end %>
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %> <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
</p> </p>

View file

@ -1,14 +1,13 @@
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3"> <div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<h2> <h2><%= icon_div @conn, "bi-door-open", [class: "icon baseline"] %>
<i class="bi bi-door-open me-1"></i> Log in Log in</h2>
</h2>
<p class="lead">Who goes there?</p> <p class="lead">Who goes there?</p>
<%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user, class: "needs-validation", novalidate: true], fn f -> %> <%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user, class: "needs-validation", novalidate: true], fn f -> %>
<%= if @error_message do %> <%= if @error_message do %>
<div class="alert alert-danger alert-dismissible fade show mt-4" role="alert"> <div class="alert alert-danger alert-dismissible fade show" role="alert">
<%= @error_message %> <%= @error_message %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
@ -16,12 +15,11 @@
<%= label f, :email, class: "form-label" %> <%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3"> <div class="inner-addon left-addon mb-3">
<i class="bi bi-at icon is-left text-muted fs-5"></i> <%= icon_div @conn, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<%= email_input f, :email, <%= email_input f, :email,
class: "form-control", class: "form-control",
placeholder: "e.g., babka@73k.us", placeholder: "e.g., babka@73k.us",
maxlength: User.max_email, maxlength: User.max_email,
autofocus: true,
required: true required: true
%> %>
<span class="invalid-feedback">must be a valid email address</span> <span class="invalid-feedback">must be a valid email address</span>
@ -29,7 +27,7 @@
<%= label f, :password, class: "form-label" %> <%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3"> <div class="inner-addon left-addon mb-3">
<i class="bi bi-lock icon is-left text-muted fs-5"></i> <%= icon_div @conn, "bi-lock", [class: "icon is-left text-muted fs-5"] %>
<%= password_input f, :password, <%= password_input f, :password,
class: "form-control", class: "form-control",
required: true required: true
@ -49,9 +47,7 @@
<% end %> <% end %>
<p> <p>
<%= if allow_registration() do %> <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<% end %>
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %> <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
</p> </p>

View file

@ -2,7 +2,8 @@
<div class="col-12 col-md-10 col-xl-8"> <div class="col-12 col-md-10 col-xl-8">
<h2> <h2>
<i class="bi bi-file-earmark-spreadsheet me-1"></i> CSV Export <%= icon_div @conn, "bi-file-earmark-spreadsheet", [class: "icon baseline"] %>
CSV Export
</h2> </h2>
<p class="lead">Select a date range for which to export a CSV of your scheduled shifts, or click "Export All" to export everything.</p> <p class="lead">Select a date range for which to export a CSV of your scheduled shifts, or click "Export All" to export everything.</p>
@ -26,7 +27,6 @@
value: Date.beginning_of_month(today), value: Date.beginning_of_month(today),
min: min_date, min: min_date,
max: max_date, max: max_date,
required: true,
class: "form-control" class: "form-control"
%> %>
</div> </div>
@ -37,7 +37,6 @@
value: Date.end_of_month(today), value: Date.end_of_month(today),
min: min_date, min: min_date,
max: max_date, max: max_date,
required: true,
class: "form-control" class: "form-control"
%> %>
</div> </div>

View file

@ -2,7 +2,8 @@
<div class="col-12 col-md-10 col-xl-8"> <div class="col-12 col-md-10 col-xl-8">
<h2> <h2>
<i class="bi bi-calendar2 me-1"></i> User Shifts ICS <%= icon_div @conn, "bi-calendar2", [class: "icon baseline"] %>
User Shifts ICS
</h2> </h2>
<p class="lead">Shifts for user: <%= @user.email %></p> <p class="lead">Shifts for user: <%= @user.email %></p>
<p>Calendar slug: <%= @slug %></p> <p>Calendar slug: <%= @slug %></p>

View file

@ -0,0 +1,38 @@
defmodule Shift73kWeb.IconHelpers do
@moduledoc """
Generate SVG sprite use tags for SVG icons
"""
use Phoenix.HTML
alias Shift73kWeb.Router.Helpers, as: Routes
def icon_div(conn, name, div_opts \\ [], svg_opts \\ []) do
content_tag(:div, tag_opts(name, div_opts)) do
icon_svg(conn, name, svg_opts)
end
end
def icon_svg(conn, name, opts \\ []) do
opts = aria_hidden?(opts)
content_tag(:svg, tag_opts(name, opts)) do
~E"""
<%= if title = Keyword.get(opts, :aria_label), do: content_tag(:title, title) %>
<%= tag(:use, "xlink:href": Routes.static_path(conn, "/images/icons.svg##{name}")) %>
"""
end
end
defp tag_opts(name, opts) do
Keyword.update(opts, :class, name, fn c -> "#{c} #{name}" end)
end
defp aria_hidden?(opts) do
case Keyword.get(opts, :aria_hidden) do
"false" -> Keyword.drop(opts, [:aria_hidden])
false -> Keyword.drop(opts, [:aria_hidden])
"true" -> opts
_ -> Keyword.put(opts, :aria_hidden, "true")
end
end
end

View file

@ -1,20 +1,9 @@
defmodule Shift73kWeb.LayoutView do defmodule Shift73kWeb.LayoutView do
use Shift73kWeb, :view use Shift73kWeb, :view
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Repo
alias Shift73k.Accounts.User alias Shift73k.Accounts.User
alias Shift73kWeb.Roles alias Shift73kWeb.Roles
# With a Vite.js-based workflow, we will import different asset files in development
# and in production builds. Therefore, we will need a way to conditionally render
# <script> tags based on Mix environment. However, since Mix is not available in
# releases, we need to cache the Mix environment at compile time. To this end:
@env Mix.env() # remember value at compile time
def dev_env?, do: @env == :dev
def allow_registration, do: get_app_allow_reg()
def nav_link_opts(conn, opts) do def nav_link_opts(conn, opts) do
case Keyword.get(opts, :to) == Phoenix.Controller.current_path(conn) do case Keyword.get(opts, :to) == Phoenix.Controller.current_path(conn) do
false -> opts false -> opts

View file

@ -1,7 +1,4 @@
defmodule Shift73kWeb.UserConfirmationView do defmodule Shift73kWeb.UserConfirmationView do
use Shift73kWeb, :view use Shift73kWeb, :view
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Accounts.User alias Shift73k.Accounts.User
def allow_registration, do: get_app_allow_reg()
end end

View file

@ -1,7 +1,4 @@
defmodule Shift73kWeb.UserResetPasswordView do defmodule Shift73kWeb.UserResetPasswordView do
use Shift73kWeb, :view use Shift73kWeb, :view
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Accounts.User alias Shift73k.Accounts.User
def allow_registration, do: get_app_allow_reg()
end end

View file

@ -1,7 +1,4 @@
defmodule Shift73kWeb.UserSessionView do defmodule Shift73kWeb.UserSessionView do
use Shift73kWeb, :view use Shift73kWeb, :view
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Accounts.User alias Shift73k.Accounts.User
def allow_registration, do: get_app_allow_reg()
end end

42
mix.exs
View file

@ -4,10 +4,10 @@ defmodule Shift73k.MixProject do
def project do def project do
[ [
app: :shift73k, app: :shift73k,
version: "0.2.1", version: "0.1.0",
elixir: "~> 1.12", elixir: "~> 1.7",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
compilers: Mix.compilers(), compilers: [:phoenix, :gettext] ++ Mix.compilers(),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
aliases: aliases(), aliases: aliases(),
deps: deps() deps: deps()
@ -33,30 +33,33 @@ defmodule Shift73k.MixProject do
# Type `mix help deps` for examples and options. # Type `mix help deps` for examples and options.
defp deps do defp deps do
[ [
{:bcrypt_elixir, "~> 3.0"}, {:bcrypt_elixir, "~> 2.0"},
{:phoenix, "~> 1.6.11"}, {:phoenix, "~> 1.5.8"},
{:phoenix_ecto, "~> 4.4"}, {:phoenix_ecto, "~> 4.1"},
{:ecto_sql, "~> 3.6"}, {:ecto_sql, "~> 3.4"},
{:postgrex, ">= 0.0.0"}, {:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 3.0"}, {:phoenix_live_view, "~> 0.15.0"},
{:floki, ">= 0.27.0", only: :test},
{:phoenix_html, "~> 2.11"},
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 0.18"}, {:phoenix_live_dashboard, "~> 0.4"},
{:floki, ">= 0.30.0", only: :test}, {:telemetry_metrics, "~> 0.4"},
{:swoosh, "~> 1.7"}, {:telemetry_poller, "~> 0.4"},
{:gen_smtp, "~> 1.2"}, {:gettext, "~> 0.11"},
{:telemetry_metrics, "~> 0.6"}, {:jason, "~> 1.0"},
{:telemetry_poller, "~> 1.0"}, {:plug_cowboy, "~> 2.0"},
{:jason, "~> 1.2"}, {:bamboo, "~> 2.0"},
{:plug_cowboy, "~> 2.5"}, {:bamboo_smtp, "~> 4.0"},
{:scrivener_ecto, "~> 2.0"}, {:scrivener_ecto, "~> 2.0"},
{:tzdata, "~> 1.1"}, {:tzdata, "~> 1.1"},
{:nimble_csv, "~> 1.0"}, {:nimble_csv, "~> 1.0"},
{:icalendar, "~> 1.1"}, {:icalendar, "~> 1.1"},
{:httpoison, "~> 2.0"}, {:httpoison, "~> 1.7"},
# Additional packages # Additional packages
{:credo, "~> 1.5", only: [:dev, :test], runtime: false} {:credo, "~> 1.5", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.8", only: :dev}
] ]
end end
@ -71,8 +74,7 @@ defmodule Shift73k.MixProject do
setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"], setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"], "ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]
"assets.deploy": ["cmd npm --prefix assets run build", "phx.digest"]
] ]
end end
end end

View file

@ -1,56 +1,60 @@
%{ %{
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"}, "bamboo": {:hex, :bamboo, "2.0.2", "0e2914d2bea0de3b1743384c24ffbe20fbb58094376a49f1cf5d9ed9959abd82", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "058d57cf4fcdac19413aa72732eb43c88954fb151a1cb6a382014e0cddbf6314"},
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "bamboo_smtp": {:hex, :bamboo_smtp, "4.0.0", "0cc7df161d5d440d280a6d2eb20bf80bc45ea77161728a229e5ab339dcd087cd", [:mix], [{:bamboo, "~> 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 1.1.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm", "2412015092121b9f24f3f2e654bcd98e5c5f9afb323a94f8defa22e70ba8f23d"},
"castore": {:hex, :castore, "1.0.0", "c25cd0794c054ebe6908a86820c8b92b5695814479ec95eeff35192720b71eec", [:mix], [], "hexpm", "577d0e855983a97ca1dfa33cbb8a3b6ece6767397ffb4861514343b078fc284b"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.3.0", "6cb662d5c1b0a8858801cf20997bd006e7016aa8c52959c9ef80e0f34fb60b7a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2c81d61d4f6ed0e5cf7bf27a9109b791ff216a1034b3d541327484f46dd43769"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, "comeonin": {:hex, :comeonin, "5.3.2", "5c2f893d05c56ae3f5e24c1b983c2d5dfb88c6d979c9287a76a7feb1e1d8d646", [:mix], [], "hexpm", "d0993402844c49539aeadb3fe46a3c9bd190f1ecf86b6f9ebd71957534c95f04"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"},
"credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"}, "credo": {:hex, :credo, "1.5.5", "e8f422026f553bc3bebb81c8e8bf1932f498ca03339856c7fec63d3faac8424b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd8623ab7091956a855dc9f3062486add9c52d310dfd62748779c4315d8247de"},
"db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"}, "db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"ecto": {:hex, :ecto, "3.9.4", "3ee68e25dbe0c36f980f1ba5dd41ee0d3eb0873bccae8aeaf1a2647242bffa35", [:mix], [{:decimal, "~> 1.6 or ~> 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", "de5f988c142a3aa4ec18b85a4ec34a2390b65b24f02385c1144252ff6ff8ee75"}, "ecto": {:hex, :ecto, "3.6.0", "df6b00f7278b458108044da4cff365dde31f6f2f621cf7dc0bf857b26be3bd20", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3035603f5b308ea7731b854493e5b5c1565e4d1e073186c3963b9689304f1d08"},
"ecto_sql": {:hex, :ecto_sql, "3.9.2", "34227501abe92dba10d9c3495ab6770e75e79b836d114c41108a4bf2ce200ad5", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 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", "1eb5eeb4358fdbcd42eac11c1fbd87e3affd7904e639d77903c1358b2abd3f70"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
"elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"}, "ecto_sql": {:hex, :ecto_sql, "3.6.0", "5cb277b086618a644f2c5450316202a885716bb7726b9f13b74cb0708bea3a8f", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.6.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3486d6e29ee4a0e7a381390c9c289bfbbaf5dc1971e269c579799d2300e5bd5"},
"elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"floki": {:hex, :floki, "0.34.0", "002d0cc194b48794d74711731db004fafeb328fe676976f160685262d43706a8", [:mix], [], "hexpm", "9c3a9f43f40dde00332a589bd9d389b90c1f518aef500364d00636acc5ebc99c"}, "floki": {:hex, :floki, "0.30.1", "75d35526d3a1459920b6e87fdbc2e0b8a3670f965dd0903708d2b267e0904c55", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e9c03524447d1c4cbfccd672d739b8c18453eee377846b119d4fd71b1a176bb8"},
"gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"}, "gen_smtp": {:hex, :gen_smtp, "1.1.0", "b0c92138f69e2f73e1eb791075e93e952efcbc231a536740749b02a1a57155a3", [:rebar3], [{:hut, "1.3.0", [hex: :hut, repo: "hexpm", optional: false]}, {:ranch, ">= 1.7.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e53a13c4bb662331bdf4aa47f00982ef49ce2b4e5c703240542cb7b28f23546a"},
"gettext": {:hex, :gettext, "0.20.0", "75ad71de05f2ef56991dbae224d35c68b098dd0e26918def5bb45591d5c8d429", [:mix], [], "hexpm", "1c03b177435e93a47441d7f681a7040bd2a816ece9e2666d1c9001035121eb3d"}, "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"httpoison": {:hex, :httpoison, "2.0.0", "d38b091f5e481e45cc700aba8121ce49b66d348122a097c9fbc2dc6876d88090", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "f1253bf455be73a4c3f6ae3407e7e3cf6fc91934093e056d737a0566126e2930"}, "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"},
"icalendar": {:hex, :icalendar, "1.1.2", "5d0afff5d0143c5bd43f18ae32a777bf0fb9a724543ab05229a460d368f0a5e7", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "2060f8e353fdf3047e95a3f012583dc3c0bbd7ca1010e32ed9e9fc5760ad4292"}, "hut": {:hex, :hut, "1.3.0", "71f2f054e657c03f959cf1acc43f436ea87580696528ca2a55c8afb1b06c85e7", [:"erlang.mk", :rebar, :rebar3], [], "hexpm", "7e15d28555d8a1f2b5a3a931ec120af0753e4853a4c66053db354f35bf9ab563"},
"icalendar": {:hex, :icalendar, "1.1.0", "898a8640abb32d161d990e419999004718a7a4b48be31f48db248f90ca33fa6e", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "a131f45fbabd2ee5a22e6bc49ea91e81131158394e7169274cee866263640dca"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "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.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, "mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"}, "nimble_csv": {:hex, :nimble_csv, "1.1.0", "b1dba4a86be9e03065c9de829050468e591f569100332db949e7ce71be0afc25", [:mix], [], "hexpm", "e986755bc302832cac429be6deda0fc9d82d3c82b47abefb68b3c17c9d949a3f"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"phoenix": {:hex, :phoenix, "1.6.15", "0a1d96bbc10747fd83525370d691953cdb6f3ccbac61aa01b4acb012474b047d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d70ab9fbf6b394755ea88b644d34d79d8b146e490973151f248cacd122d20672"}, "phoenix": {:hex, :phoenix, "1.5.8", "71cfa7a9bb9a37af4df98939790642f210e35f696b935ca6d9d9c55a884621a4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35ded0a32f4836168c7ab6c33b88822eccd201bcd9492125a9bea4c54332d955"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.4.0", "87990e68b60213d7487e65814046f9a2bed4a67886c943270125913499b3e5c3", [:mix], [{:ecto_psql_extras, "~> 0.4.1 or ~> 0.5", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "8d52149e58188e9e4497cc0d8900ab94d9b66f96998ec38c47c7a4f8f4f50e57"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.18.11", "c50eac83dae6b5488859180422dfb27b2c609de87f4aa5b9c926ecd0501cd44f", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [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]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76c99a0ffb47cd95bf06a917e74f282a603f3e77b00375f3c2dd95110971b102"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.0", "f35f61c3f959c9a01b36defaa1f0624edd55b87e236b606664a556d6f72fd2e7", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "02c1007ae393f2b76ec61c1a869b1e617179877984678babde131d716f95b582"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.15.4", "86908dc9603cc81c07e84725ee42349b5325cb250c9c20d3533856ff18dbb7dc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35d78f3c35fe10a995dca5f4ab50165b7a90cbe02e23de245381558f821e9462"},
"phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
"phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, "phx_gen_auth": {:hex, :phx_gen_auth, "0.6.0", "4ffbfa5b34ad8178c3dfcb996fed776df425903595cbc8d56a9ae5bc53136810", [:mix], [{:phoenix, "~> 1.5.2", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9a801c0f0bc251d8d91d62cecba0ebb6a90b8580fa8843029d931d15164e6ad9"},
"plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, "plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"},
"plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, "plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"},
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
"postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [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", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, "postgrex": {:hex, :postgrex, "0.15.8", "f5e782bbe5e8fa178d5e3cd1999c857dc48eda95f0a4d7f7bd92a50e84a0d491", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [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]}], "hexpm", "698fbfacea34c4cf22c8281abeb5cf68d99628d541874f085520ab3b53d356fe"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"},
"scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"}, "scrivener": {:hex, :scrivener, "2.7.0", "fa94cdea21fad0649921d8066b1833d18d296217bfdf4a5389a2f45ee857b773", [:mix], [], "hexpm", "30da36a427f2519cf75993271fb7c5aad1759682a70f90d880a85c3d743d2c57"},
"scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"}, "scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},
"sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"swoosh": {:hex, :swoosh, "1.9.1", "0a5d7bf9954eb41d7e55525bc0940379982b090abbaef67cd8e1fd2ed7f8ca1a", [:mix], [{: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]}, {: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]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76dffff3ffcab80f249d5937a592eaef7cc49ac6f4cdd27e622868326ed6371e"}, "swoosh": {:hex, :swoosh, "1.3.2", "608819a638d527d0fd6e8892db431edd3f93c0452499880f94a80bbbcf8377e6", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, 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]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "2c77c2eef273de37283bdc09c6506d466928f897600ea258fbb4765bbffd6ab2"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.0", "da9d49ee7e6bb1c259d36ce6539cd45ae14d81247a2b0c90edf55e2b50507f7b", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5cfe67ad464b243835512aa44321cee91faed6ea868d7fb761d7016e02915c3d"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, "telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"},
"timex": {:hex, :timex, "3.7.9", "790cdfc4acfce434e442f98c02ea6d84d0239073bfd668968f82ac63e9a6788d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "64691582e5bb87130f721fc709acfb70f24405833998fabf35be968984860ce1"}, "timex": {:hex, :timex, "3.7.3", "df8a2ea814749d700d6878ab9eacac9fdb498ecee2f507cb0002ec172bc24d0f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8691c1d86ca3a7bc14a156e2199dc8927be95d1a8f0e3b69e4bb2d6262c53ac6"},
"tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, "tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
} }

View file

@ -5,28 +5,28 @@ defmodule Shift73k.Repo.Migrations.CreateUsersAuthTables do
execute("CREATE EXTENSION IF NOT EXISTS citext", "") execute("CREATE EXTENSION IF NOT EXISTS citext", "")
create table(:users, primary_key: false) do create table(:users, primary_key: false) do
add :id, :binary_id, primary_key: true add(:id, :binary_id, primary_key: true)
add :email, :citext, null: false add(:email, :citext, null: false)
add :hashed_password, :string, null: false add(:hashed_password, :string, null: false)
add :role, :string, null: false add(:role, :string, null: false)
add :confirmed_at, :naive_datetime add(:confirmed_at, :naive_datetime)
add :week_start_at, :string, null: false add(:week_start_at, :string, null: false)
add :calendar_slug, :string, null: false add(:calendar_slug, :string, null: false)
timestamps() timestamps()
end end
create unique_index(:users, [:email, :calendar_slug]) create(unique_index(:users, [:email, :calendar_slug]))
create table(:users_tokens, primary_key: false) do create table(:users_tokens, primary_key: false) do
add :id, :binary_id, primary_key: true add(:id, :binary_id, primary_key: true)
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false add(:user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false)
add :token, :binary, null: false add(:token, :binary, null: false)
add :context, :string, null: false add(:context, :string, null: false)
add :sent_to, :string add(:sent_to, :string)
timestamps(updated_at: false) timestamps(updated_at: false)
end end
create index(:users_tokens, [:user_id]) create(index(:users_tokens, [:user_id]))
create unique_index(:users_tokens, [:context, :token]) create(unique_index(:users_tokens, [:context, :token]))
end end
end end

View file

@ -3,7 +3,7 @@ defmodule Shift73k.Repo.Migrations.AddUserDefaultShiftColumn do
def change do def change do
alter table(:users) do alter table(:users) do
add :fave_shift_template_id, references(:shift_templates, type: :binary_id, on_delete: :nilify_all) add(:fave_shift_template_id, references(:shift_templates, type: :binary_id, on_delete: :nilify_all))
end end
end end
end end

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