Compare commits

...

29 Commits
main ... dev

Author SHA1 Message Date
KKlochko 8a3c22dab1 Add the configuration for Drone CI/CD to run E2E tests.
continuous-integration/drone/push Build is failing Details
8 months ago
KKlochko 38e1e03b26 Add a E2E test to create new link without a random shorten.
8 months ago
KKlochko 9e22e1bfde Add the configuration for wallaby.
8 months ago
KKlochko ddf54a9264 Add wallaby and flop as dependencies.
8 months ago
KKlochko d0646b93f9 Update the form for Link to create with a random shorten if needed.
continuous-integration/drone/push Build is passing Details
8 months ago
KKlochko 74136c7fa7 Update the Links.create_one to support options for the generator.
continuous-integration/drone/push Build is passing Details
8 months ago
KKlochko 8ddb7da76e Update the generator to use options and support a string-based map.
8 months ago
KKlochko 158dcce49b Refactor the test for SafeString.
8 months ago
KKlochko 00467d249a Add tests for Links' LiveViews.
continuous-integration/drone/push Build is passing Details
8 months ago
KKlochko 15d12bd937 Update the show LiveView template for Links.
8 months ago
KKlochko d3c3e210d5 Add CRUD LiveViews for Links.
continuous-integration/drone/push Build is passing Details
8 months ago
KKlochko 1e15bc3603 Add a function to get all user's links.
8 months ago
KKlochko d2f8543fdd Update the edit function in Links to support existing links.
8 months ago
KKlochko 6300a1a8b7 Update the UserFactory filename to the snake_case.
continuous-integration/drone/push Build is passing Details
8 months ago
KKlochko f2fe354e43 Update the create link route to add the relationship to the user.
8 months ago
KKlochko 8d3d619e7c Update the tests for Links.
8 months ago
KKlochko 9b2f9aaa8c Update the links fixture to use the factory.
8 months ago
KKlochko 83bf6ce558 Add LinkFactory to create links.
8 months ago
KKlochko 499c5e5cc2 Add a relationship between a user and links.
8 months ago
KKlochko 1b6651bab9 Add the create_user to the UserFactory.
8 months ago
KKlochko 9671d030c4 Update the user_factory to the user_attrs_factory.
8 months ago
KKlochko 31d0e01e95 Update the routes for Accounts to use hyphens.
continuous-integration/drone/push Build is passing Details
8 months ago
KKlochko 780652241f Add the logout for the API.
continuous-integration/drone/push Build is passing Details
8 months ago
KKlochko 2f4fc790be Add Guardian.DB to track API tokens.
8 months ago
KKlochko 8697be9f17 Update the API Authentication.
continuous-integration/drone/push Build is passing Details
9 months ago
KKlochko 32ea2c4ff8 Update the Authentication.
continuous-integration/drone/push Build is passing Details
9 months ago
KKlochko 61af12a52a Update the RedirectionController.
continuous-integration/drone/push Build is passing Details
9 months ago
KKlochko 59de90601a Update the link api.
continuous-integration/drone/push Build is passing Details
9 months ago
KKlochko 18bb0b13b6 Update the phoenix version and starting migration the project.
continuous-integration/drone/push Build is passing Details
9 months ago

@ -9,6 +9,7 @@ environment:
DATABASE_HOST: postgres DATABASE_HOST: postgres
DATABASE_PORT: 5432 DATABASE_PORT: 5432
SECRET_KEY_BASE: TestSecretKeycRZ1H45gfdAsdf8DIb45df4664mRCruzwVEF68wosddfg324ost SECRET_KEY_BASE: TestSecretKeycRZ1H45gfdAsdf8DIb45df4664mRCruzwVEF68wosddfg324ost
SELENIUM_REMOTE_URL: http://standalone-firefox:4444/wd/hub/
steps: steps:
- name: install dependencies - name: install dependencies
@ -49,4 +50,6 @@ services:
POSTGRES_DB: link_shortener_dev POSTGRES_DB: link_shortener_dev
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
- name: standalone-firefox
image: selenium/standalone-firefox:129.0

@ -1,5 +1,6 @@
[ [
import_deps: [:ecto, :phoenix], import_deps: [:ecto, :ecto_sql, :phoenix],
inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], subdirectories: ["priv/*/migrations"],
subdirectories: ["priv/*/migrations"] plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
] ]

3
.gitignore vendored

@ -24,6 +24,9 @@ erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build"). # Also ignore archive artifacts (built via "mix archive.build").
*.ez *.ez
# Temporary files, for example, from tests.
/tmp/
# Ignore package tarball (built via "mix hex.build"). # Ignore package tarball (built via "mix hex.build").
link_shortener-*.tar link_shortener-*.tar

@ -1,120 +1,5 @@
/* This file is for your main application CSS */ @import "tailwindcss/base";
@import "./phoenix.css"; @import "tailwindcss/components";
@import "tailwindcss/utilities";
/* Alerts and form errors used by phx.new */
.alert {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
}
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
}
.alert-danger {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
}
.alert p {
margin-bottom: 0;
}
.alert:empty {
display: none;
}
.invalid-feedback {
color: #a94442;
display: block;
margin: -1rem 0 2rem;
}
/* LiveView specific classes for your customization */
.phx-no-feedback.invalid-feedback,
.phx-no-feedback .invalid-feedback {
display: none;
}
.phx-click-loading {
opacity: 0.5;
transition: opacity 1s ease-out;
}
.phx-loading{
cursor: wait;
}
.phx-modal {
opacity: 1!important;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.phx-modal-content {
background-color: #fefefe;
margin: 15vh auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
}
.phx-modal-close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.phx-modal-close:hover, /* This file is for your main application CSS */
.phx-modal-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.fade-in-scale {
animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys;
}
.fade-out-scale {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys;
}
.fade-in {
animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
}
.fade-out {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys;
}
@keyframes fade-in-scale-keys{
0% { scale: 0.95; opacity: 0; }
100% { scale: 1.0; opacity: 1; }
}
@keyframes fade-out-scale-keys{
0% { scale: 1.0; opacity: 1; }
100% { scale: 0.95; opacity: 0; }
}
@keyframes fade-in-keys{
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out-keys{
0% { opacity: 1; }
100% { opacity: 0; }
}

File diff suppressed because one or more lines are too long

@ -1,7 +1,3 @@
// We import the CSS which is extracted to its own file by esbuild.
// Remove this line if you add a your own CSS build pipeline (e.g postcss).
import "../css/app.css"
// If you want to use Phoenix channels, run `mix help phx.gen.channel` // If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below. // to get started and then uncomment the line below.
// import "./user_socket.js" // import "./user_socket.js"
@ -27,12 +23,15 @@ import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar" import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}
})
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", info => topbar.show()) window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", info => topbar.hide()) window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page // connect if there are any LiveViews on the page
liveSocket.connect() liveSocket.connect()

@ -0,0 +1,74 @@
// See the Tailwind configuration guide for advanced usage
// https://tailwindcss.com/docs/configuration
const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")
module.exports = {
content: [
"./js/**/*.js",
"../lib/link_shortener_web.ex",
"../lib/link_shortener_web/**/*.*ex"
],
theme: {
extend: {
colors: {
brand: "#FD4F00",
}
},
},
plugins: [
require("@tailwindcss/forms"),
// Allows prefixing tailwind classes with LiveView classes to add rules
// only when LiveView classes are applied, for example:
//
// <div class="phx-click-loading:animate-ping">
//
plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
// Embeds Heroicons (https://heroicons.com) into your app.css bundle
// See your `CoreComponents.icon/1` for more information.
//
plugin(function({matchComponents, theme}) {
let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
let values = {}
let icons = [
["", "/24/outline"],
["-solid", "/24/solid"],
["-mini", "/20/solid"],
["-micro", "/16/solid"]
]
icons.forEach(([suffix, dir]) => {
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
let name = path.basename(file, ".svg") + suffix
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
})
})
matchComponents({
"hero": ({name, fullPath}) => {
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
let size = theme("spacing.6")
if (name.endsWith("-mini")) {
size = theme("spacing.5")
} else if (name.endsWith("-micro")) {
size = theme("spacing.4")
}
return {
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
"-webkit-mask": `var(--hero-${name})`,
"mask": `var(--hero-${name})`,
"mask-repeat": "no-repeat",
"background-color": "currentColor",
"vertical-align": "middle",
"display": "inline-block",
"width": size,
"height": size
}
}
}, {values})
})
]
}

@ -1,6 +1,6 @@
/** /**
* @license MIT * @license MIT
* topbar 1.0.0, 2021-01-06 * topbar 2.0.0, 2023-02-04
* https://buunguyen.github.io/topbar * https://buunguyen.github.io/topbar
* Copyright (c) 2021 Buu Nguyen * Copyright (c) 2021 Buu Nguyen
*/ */
@ -35,10 +35,11 @@
})(); })();
var canvas, var canvas,
progressTimerId,
fadeTimerId,
currentProgress, currentProgress,
showing, showing,
progressTimerId = null,
fadeTimerId = null,
delayTimerId = null,
addEvent = function (elem, type, handler) { addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false); if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler); else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
@ -95,21 +96,26 @@
for (var key in opts) for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key]; if (options.hasOwnProperty(key)) options[key] = opts[key];
}, },
show: function () { show: function (delay) {
if (showing) return; if (showing) return;
showing = true; if (delay) {
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); if (delayTimerId) return;
if (!canvas) createCanvas(); delayTimerId = setTimeout(() => topbar.show(), delay);
canvas.style.opacity = 1; } else {
canvas.style.display = "block"; showing = true;
topbar.progress(0); if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (options.autoRun) { if (!canvas) createCanvas();
(function loop() { canvas.style.opacity = 1;
progressTimerId = window.requestAnimationFrame(loop); canvas.style.display = "block";
topbar.progress( topbar.progress(0);
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) if (options.autoRun) {
); (function loop() {
})(); progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
} }
}, },
progress: function (to) { progress: function (to) {
@ -125,6 +131,8 @@
return currentProgress; return currentProgress;
}, },
hide: function () { hide: function () {
clearTimeout(delayTimerId);
delayTimerId = null;
if (!showing) return; if (!showing) return;
showing = false; showing = false;
if (progressTimerId != null) { if (progressTimerId != null) {

@ -8,14 +8,19 @@
import Config import Config
config :link_shortener, config :link_shortener,
ecto_repos: [LinkShortener.Repo] ecto_repos: [LinkShortener.Repo],
generators: [timestamp_type: :utc_datetime]
# Configures the endpoint # Configures the endpoint
config :link_shortener, LinkShortenerWeb.Endpoint, config :link_shortener, LinkShortenerWeb.Endpoint,
url: [host: "localhost"], url: [host: "localhost"],
render_errors: [view: LinkShortenerWeb.ErrorView, accepts: ~w(html json), layout: false], adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: LinkShortenerWeb.ErrorHTML, json: LinkShortenerWeb.ErrorJSON],
layout: false
],
pubsub_server: LinkShortener.PubSub, pubsub_server: LinkShortener.PubSub,
live_view: [signing_salt: "8wxfzzEQ"] live_view: [signing_salt: "+S5BXaoX"]
# Configures the mailer # Configures the mailer
# #
@ -26,19 +31,28 @@ config :link_shortener, LinkShortenerWeb.Endpoint,
# at the `config/runtime.exs`. # at the `config/runtime.exs`.
config :link_shortener, LinkShortener.Mailer, adapter: Swoosh.Adapters.Local config :link_shortener, LinkShortener.Mailer, adapter: Swoosh.Adapters.Local
# Swoosh API client is needed for adapters other than SMTP.
config :swoosh, :api_client, false
# Configure esbuild (the version is required) # Configure esbuild (the version is required)
config :esbuild, config :esbuild,
version: "0.14.29", version: "0.17.11",
default: [ link_shortener: [
args: args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__), cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
] ]
# Configure tailwind (the version is required)
config :tailwind,
version: "3.4.3",
link_shortener: [
args: ~w(
--config=tailwind.config.js
--input=css/app.css
--output=../priv/static/assets/app.css
),
cd: Path.expand("../assets", __DIR__)
]
# 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",
@ -51,6 +65,10 @@ config :link_shortener, LinkShortenerWeb.Auth.Guardian,
issuer: "link_shortener", issuer: "link_shortener",
secret_key: System.get_env("SECRET_KEY_BASE") secret_key: System.get_env("SECRET_KEY_BASE")
config :guardian, Guardian.DB,
repo: LinkShortener.Repo,
schema_name: "guardian_tokens"
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs" import_config "#{config_env()}.exs"

@ -2,10 +2,10 @@ import Config
# Configure your database # Configure your database
config :link_shortener, LinkShortener.Repo, config :link_shortener, LinkShortener.Repo,
database: System.get_env("DATABASE_NAME"),
username: System.get_env("DATABASE_USERNAME"), username: System.get_env("DATABASE_USERNAME"),
password: System.get_env("DATABASE_PASSWORD"), password: System.get_env("DATABASE_PASSWORD"),
hostname: System.get_env("DATABASE_HOST"), hostname: System.get_env("DATABASE_HOST"),
database: System.get_env("DATABASE_NAME"),
port: System.get_env("DATABASE_PORT"), port: System.get_env("DATABASE_PORT"),
stacktrace: true, stacktrace: true,
show_sensitive_data_on_connection_error: true, show_sensitive_data_on_connection_error: true,
@ -15,8 +15,8 @@ config :link_shortener, LinkShortener.Repo,
# debugging and code reloading. # debugging and code reloading.
# #
# The watchers configuration can be used to run external # The watchers configuration can be used to run external
# watchers to your application. For example, we use it # watchers to your application. For example, we can use it
# with esbuild to bundle .js and .css sources. # to bundle .js and .css sources.
config :link_shortener, LinkShortenerWeb.Endpoint, config :link_shortener, LinkShortenerWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines. # Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
@ -26,8 +26,8 @@ config :link_shortener, LinkShortenerWeb.Endpoint,
debug_errors: true, debug_errors: true,
secret_key_base: System.get_env("SECRET_KEY_BASE"), secret_key_base: System.get_env("SECRET_KEY_BASE"),
watchers: [ watchers: [
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) esbuild: {Esbuild, :install_and_run, [:link_shortener, ~w(--sourcemap=inline --watch)]},
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} tailwind: {Tailwind, :install_and_run, [:link_shortener, ~w(--watch)]}
] ]
# ## SSL Support # ## SSL Support
@ -38,7 +38,6 @@ config :link_shortener, LinkShortenerWeb.Endpoint,
# #
# mix phx.gen.cert # mix phx.gen.cert
# #
# Note that this task requires Erlang/OTP 20 or later.
# Run `mix help phx.gen.cert` for more information. # Run `mix help phx.gen.cert` for more information.
# #
# The `http:` config above can be replaced with: # The `http:` config above can be replaced with:
@ -58,13 +57,15 @@ config :link_shortener, LinkShortenerWeb.Endpoint,
config :link_shortener, LinkShortenerWeb.Endpoint, config :link_shortener, LinkShortenerWeb.Endpoint,
live_reload: [ live_reload: [
patterns: [ patterns: [
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$", ~r"priv/gettext/.*(po)$",
~r"lib/link_shortener_web/(live|views)/.*(ex)$", ~r"lib/link_shortener_web/(controllers|live|components)/.*(ex|heex)$"
~r"lib/link_shortener_web/templates/.*(eex)$"
] ]
] ]
# Enable dev routes for dashboard and mailbox
config :link_shortener, dev_routes: true
# Do not include metadata nor timestamps in development logs # Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n" config :logger, :console, format: "[$level] $message\n"
@ -74,3 +75,12 @@ 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
config :phoenix_live_view,
# Include HEEx debug annotations as HTML comments in rendered markup
debug_heex_annotations: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false

@ -28,7 +28,7 @@ if config_env() == :prod do
For example: ecto://USER:PASS@HOST/DATABASE For example: ecto://USER:PASS@HOST/DATABASE
""" """
maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :link_shortener, LinkShortener.Repo, config :link_shortener, LinkShortener.Repo,
# ssl: true, # ssl: true,
@ -51,18 +51,52 @@ if config_env() == :prod do
host = System.get_env("PHX_HOST") || "example.com" host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000") port = String.to_integer(System.get_env("PORT") || "4000")
config :link_shortener, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :link_shortener, LinkShortenerWeb.Endpoint, config :link_shortener, LinkShortenerWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"], url: [host: host, port: 443, scheme: "https"],
http: [ http: [
# Enable IPv6 and bind on all interfaces. # Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses. # for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0}, ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port port: port
], ],
secret_key_base: secret_key_base secret_key_base: secret_key_base
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :link_shortener, LinkShortenerWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :link_shortener, LinkShortenerWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer # ## Configuring the mailer
# #
# In production you need to configure the mailer to use a different adapter. # In production you need to configure the mailer to use a different adapter.

@ -14,20 +14,37 @@ config :link_shortener, LinkShortener.Repo,
hostname: System.get_env("DATABASE_HOST"), hostname: System.get_env("DATABASE_HOST"),
database: "link_shortener_test#{System.get_env("MIX_TEST_PARTITION")}", database: "link_shortener_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10 pool_size: System.schedulers_online() * 2
# 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.
config :link_shortener, LinkShortenerWeb.Endpoint, config :link_shortener, LinkShortenerWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002], http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: System.get_env("SECRET_KEY_BASE"), secret_key_base: System.get_env("SECRET_KEY_BASE"),
server: false server: true
# In test we don't send emails. # In test we don't send emails
config :link_shortener, LinkShortener.Mailer, adapter: Swoosh.Adapters.Test config :link_shortener, LinkShortener.Mailer, adapter: Swoosh.Adapters.Test
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test # Print only warnings and errors during test
config :logger, level: :warn config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation # Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime
# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
enable_expensive_runtime_checks: true
config :wallaby,
otp_app: :link_shortener,
screenshot_on_failure: true,
screenshot_dir: "screenshots",
driver: Wallaby.Selenium,
selenium: [
remote_url: System.get_env("SELENIUM_REMOTE_URL", "http://localhost:4444/wd/hub/")
]

@ -90,7 +90,7 @@ defmodule LinkShortener.Accounts do
""" """
def change_user_registration(%User{} = user, attrs \\ %{}) do def change_user_registration(%User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false) User.registration_changeset(user, attrs, hash_password: false, validate_email: false)
end end
## Settings ## Settings
@ -105,7 +105,7 @@ defmodule LinkShortener.Accounts do
""" """
def change_user_email(user, attrs \\ %{}) do def change_user_email(user, attrs \\ %{}) do
User.email_changeset(user, attrs) User.email_changeset(user, attrs, validate_email: false)
end end
@doc """ @doc """
@ -154,19 +154,19 @@ defmodule LinkShortener.Accounts do
Ecto.Multi.new() Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset) |> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context]))
end end
@doc """ @doc ~S"""
Delivers the update email instructions to the given user. Delivers the update email instructions to the given user.
## Examples ## Examples
iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1)) iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1}"))
{:ok, %{to: ..., body: ...}} {:ok, %{to: ..., body: ...}}
""" """
def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun) def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
@ -207,7 +207,7 @@ defmodule LinkShortener.Accounts do
Ecto.Multi.new() Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset) |> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{user: user}} -> {:ok, user} {:ok, %{user: user}} -> {:ok, user}
@ -237,22 +237,22 @@ defmodule LinkShortener.Accounts do
@doc """ @doc """
Deletes the signed token with the given context. Deletes the signed token with the given context.
""" """
def delete_session_token(token) do def delete_user_session_token(token) do
Repo.delete_all(UserToken.token_and_context_query(token, "session")) Repo.delete_all(UserToken.by_token_and_context_query(token, "session"))
:ok :ok
end end
## Confirmation ## Confirmation
@doc """ @doc ~S"""
Delivers the confirmation email instructions to the given user. Delivers the confirmation email instructions to the given user.
## Examples ## Examples
iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :edit, &1)) iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}"))
{:ok, %{to: ..., body: ...}} {:ok, %{to: ..., body: ...}}
iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :edit, &1)) iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}"))
{:error, :already_confirmed} {:error, :already_confirmed}
""" """
@ -286,17 +286,17 @@ defmodule LinkShortener.Accounts do
defp confirm_user_multi(user) do defp confirm_user_multi(user) do
Ecto.Multi.new() Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.confirm_changeset(user)) |> Ecto.Multi.update(:user, User.confirm_changeset(user))
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"]))
end end
## Reset password ## Reset password
@doc """ @doc ~S"""
Delivers the reset password email to the given user. Delivers the reset password email to the given user.
## Examples ## Examples
iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1)) iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}"))
{:ok, %{to: ..., body: ...}} {:ok, %{to: ..., body: ...}}
""" """
@ -343,7 +343,7 @@ defmodule LinkShortener.Accounts do
def reset_user_password(user, attrs) do def reset_user_password(user, attrs) do
Ecto.Multi.new() Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) |> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{user: user}} -> {:ok, user} {:ok, %{user: user}} -> {:ok, user}

@ -2,13 +2,17 @@ defmodule LinkShortener.Accounts.User do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias LinkShortener.Links.Link
schema "users" do schema "users" do
field :email, :string field :email, :string
field :password, :string, virtual: true, redact: true field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true field :hashed_password, :string, redact: true
field :confirmed_at, :naive_datetime field :current_password, :string, virtual: true, redact: true
field :confirmed_at, :utc_datetime
has_many :links, Link
timestamps() timestamps(type: :utc_datetime)
end end
@doc """ @doc """
@ -27,27 +31,33 @@ defmodule LinkShortener.Accounts.User do
password field is not desired (like when using this changeset for password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`. validations on a LiveView form), this option can be set to `false`.
Defaults to `true`. Defaults to `true`.
* `:validate_email` - Validates the uniqueness of the email, in case
you don't want to validate the uniqueness of the email (like when
using this changeset for validations on a LiveView form before
submitting the form), this option can be set to `false`.
Defaults to `true`.
""" """
def registration_changeset(user, attrs, opts \\ []) do def registration_changeset(user, attrs, opts \\ []) do
user user
|> cast(attrs, [:email, :password]) |> cast(attrs, [:email, :password])
|> validate_email() |> validate_email(opts)
|> validate_password(opts) |> validate_password(opts)
end end
defp validate_email(changeset) do defp validate_email(changeset, opts) do
changeset changeset
|> validate_required([:email]) |> validate_required([:email])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|> validate_length(:email, max: 160) |> validate_length(:email, max: 160)
|> unsafe_validate_unique(:email, LinkShortener.Repo) |> maybe_validate_unique_email(opts)
|> unique_constraint(:email)
end end
defp validate_password(changeset, opts) do defp validate_password(changeset, opts) do
changeset changeset
|> validate_required([:password]) |> validate_required([:password])
|> validate_length(:password, min: 12, max: 72) |> validate_length(:password, min: 12, max: 72)
# Examples of additional password validation:
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
@ -62,6 +72,8 @@ defmodule LinkShortener.Accounts.User do
changeset changeset
# If using Bcrypt, then further validate it is at most 72 bytes long # If using Bcrypt, then further validate it is at most 72 bytes long
|> validate_length(:password, max: 72, count: :bytes) |> validate_length(:password, max: 72, count: :bytes)
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
# would keep the database transaction open longer and hurt performance.
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|> delete_change(:password) |> delete_change(:password)
else else
@ -69,15 +81,25 @@ defmodule LinkShortener.Accounts.User do
end end
end end
defp maybe_validate_unique_email(changeset, opts) do
if Keyword.get(opts, :validate_email, true) do
changeset
|> unsafe_validate_unique(:email, LinkShortener.Repo)
|> unique_constraint(:email)
else
changeset
end
end
@doc """ @doc """
A user changeset for changing the email. A user changeset for changing the email.
It requires the email to change otherwise an error is added. It requires the email to change otherwise an error is added.
""" """
def email_changeset(user, attrs) do def email_changeset(user, attrs, opts \\ []) do
user user
|> cast(attrs, [:email]) |> cast(attrs, [:email])
|> validate_email() |> validate_email(opts)
|> case do |> case do
%{changes: %{email: _}} = changeset -> changeset %{changes: %{email: _}} = changeset -> changeset
%{} = changeset -> add_error(changeset, :email, "did not change") %{} = changeset -> add_error(changeset, :email, "did not change")
@ -107,7 +129,7 @@ defmodule LinkShortener.Accounts.User do
Confirms the account by setting `confirmed_at`. Confirms the account by setting `confirmed_at`.
""" """
def confirm_changeset(user) do def confirm_changeset(user) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) now = DateTime.utc_now() |> DateTime.truncate(:second)
change(user, confirmed_at: now) change(user, confirmed_at: now)
end end
@ -131,6 +153,8 @@ defmodule LinkShortener.Accounts.User do
Validates the current password otherwise adds an error to the changeset. Validates the current password otherwise adds an error to the changeset.
""" """
def validate_current_password(changeset, password) do def validate_current_password(changeset, password) do
changeset = cast(changeset, %{current_password: password}, [:current_password])
if valid_password?(changeset.data, password) do if valid_password?(changeset.data, password) do
changeset changeset
else else

@ -19,7 +19,7 @@ defmodule LinkShortener.Accounts.UserToken do
field :sent_to, :string field :sent_to, :string
belongs_to :user, LinkShortener.Accounts.User belongs_to :user, LinkShortener.Accounts.User
timestamps(updated_at: false) timestamps(type: :utc_datetime, updated_at: false)
end end
@doc """ @doc """
@ -56,7 +56,7 @@ defmodule LinkShortener.Accounts.UserToken do
""" """
def verify_session_token_query(token) do def verify_session_token_query(token) do
query = query =
from token in token_and_context_query(token, "session"), from token in by_token_and_context_query(token, "session"),
join: user in assoc(token, :user), join: user in assoc(token, :user),
where: token.inserted_at > ago(@session_validity_in_days, "day"), where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: user select: user
@ -114,7 +114,7 @@ defmodule LinkShortener.Accounts.UserToken do
days = days_for_context(context) days = days_for_context(context)
query = query =
from token in token_and_context_query(hashed_token, context), from token in by_token_and_context_query(hashed_token, context),
join: user in assoc(token, :user), join: user in assoc(token, :user),
where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
select: user select: user
@ -149,7 +149,7 @@ defmodule LinkShortener.Accounts.UserToken do
hashed_token = :crypto.hash(@hash_algorithm, decoded_token) hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query = query =
from token in token_and_context_query(hashed_token, context), from token in by_token_and_context_query(hashed_token, context),
where: token.inserted_at > ago(@change_email_validity_in_days, "day") where: token.inserted_at > ago(@change_email_validity_in_days, "day")
{:ok, query} {:ok, query}
@ -162,18 +162,18 @@ defmodule LinkShortener.Accounts.UserToken do
@doc """ @doc """
Returns the token struct for the given token value and context. Returns the token struct for the given token value and context.
""" """
def token_and_context_query(token, context) do def by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context] from UserToken, where: [token: ^token, context: ^context]
end end
@doc """ @doc """
Gets all tokens for the given user for the given contexts. Gets all tokens for the given user for the given contexts.
""" """
def user_and_contexts_query(user, :all) do def by_user_and_contexts_query(user, :all) do
from t in UserToken, where: t.user_id == ^user.id from t in UserToken, where: t.user_id == ^user.id
end end
def user_and_contexts_query(user, [_ | _] = contexts) do def by_user_and_contexts_query(user, [_ | _] = contexts) do
from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts
end end
end end

@ -8,16 +8,16 @@ defmodule LinkShortener.Application do
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
children = [ children = [
# Start the Ecto repository
LinkShortener.Repo,
# Start the Telemetry supervisor
LinkShortenerWeb.Telemetry, LinkShortenerWeb.Telemetry,
# Start the PubSub system LinkShortener.Repo,
{DNSCluster, query: Application.get_env(:link_shortener, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: LinkShortener.PubSub}, {Phoenix.PubSub, name: LinkShortener.PubSub},
# Start the Endpoint (http/https) # Start the Finch HTTP client for sending emails
LinkShortenerWeb.Endpoint {Finch, name: LinkShortener.Finch},
# Start a worker by calling: LinkShortener.Worker.start_link(arg) # Start a worker by calling: LinkShortener.Worker.start_link(arg)
# {LinkShortener.Worker, arg} # {LinkShortener.Worker, arg},
# Start to serve requests, typically the last entry
LinkShortenerWeb.Endpoint
] ]
# See https://hexdocs.pm/elixir/Supervisor.html # See https://hexdocs.pm/elixir/Supervisor.html

@ -7,8 +7,28 @@ defmodule LinkShortener.Generators.LinkWithRandomShorten do
@doc """ @doc """
Generate a Link with random shorten with the length. Generate a Link with random shorten with the length.
## Options
:is_atom_based (boolean, true by default) - if a map is
atom-based, then a key for shorten will be :shorten, else
"shorten".
:length (integer, 10 by default) - the length for
the shorten.
:generator (function) - the shorten is generated by the
generator function.
LinkShortener.Generators.SafeString.generate/1 by default.
""" """
def generate_one(attrs, length \\ 10, generator \\ &SafeString.generate/1) do @spec generate_one(map(), is_atom_based: boolean(), length: integer(), generator: function()) ::
Map.put(attrs, :shorten, generator.(length)) [map()]
def generate_one(attrs, opts \\ []) do
is_atom_based = Keyword.get(opts, :is_atom_based, true)
length = Keyword.get(opts, :length, 10)
generator = Keyword.get(opts, :generator, &SafeString.generate/1)
if is_atom_based do
Map.put(attrs, :shorten, generator.(length))
else
Map.put(attrs, "shorten", generator.(length))
end
end end
end end

@ -3,10 +3,13 @@ defmodule LinkShortener.Links.Link do
import Ecto.Changeset import Ecto.Changeset
import Ecto.Query import Ecto.Query
alias LinkShortener.Accounts.User
schema "links" do schema "links" do
field :name, :string field :name, :string
field :url, :string field :url, :string
field :shorten, :string field :shorten, :string
belongs_to :user, User
timestamps() timestamps()
end end
@ -14,8 +17,8 @@ defmodule LinkShortener.Links.Link do
@doc false @doc false
def changeset(link, attrs) do def changeset(link, attrs) do
link link
|> cast(attrs, [:name, :url, :shorten]) |> cast(attrs, [:name, :url, :shorten, :user_id])
|> validate_required([:url, :shorten]) |> validate_required([:url, :shorten, :user_id])
|> unique_constraint(:shorten) |> unique_constraint(:shorten)
end end
end end

@ -5,11 +5,32 @@ defmodule LinkShortener.Links do
alias LinkShortener.Repo alias LinkShortener.Repo
alias LinkShortener.Links.Link alias LinkShortener.Links.Link
alias LinkShortener.Generators.LinkWithRandomShorten, as: LinkGenerator alias LinkShortener.Generators.LinkWithRandomShorten, as: LinkGenerator
alias LinkShortener.Accounts.User
def new_one(), do: Link.changeset(%Link{}, %{}) def new_one(), do: Link.changeset(%Link{}, %{})
def create_one(attrs, length \\ 10, generator \\ &LinkGenerator.generate_one/2) do @doc """
generator.(attrs, length) Create a Link with random shorten with the length.
## Options
:is_atom_based (boolean, true by default) - if a map is
atom-based, then a key for shorten will be :shorten, else
"shorten".
:length (integer, 10 by default) - the length for
the shorten.
:generator (function) - the shorten is generated by the
generator function.
LinkShortener.Generators.LinkWithRandomShorten.generate_one/2 by
default.
"""
@spec create_one(map(), [is_atom_based: boolean(), length: integer(), generator: function()]) ::
[map()]
def create_one(attrs, opts \\ []) do
is_atom_based = Keyword.get(opts, :is_atom_based, true)
length = Keyword.get(opts, :length, 10)
generator = Keyword.get(opts, :generator, &LinkGenerator.generate_one/2)
generator.(attrs, is_atom_based: is_atom_based, length: length)
|> insert_one() |> insert_one()
end end
@ -23,6 +44,14 @@ defmodule LinkShortener.Links do
Repo.get!(Link, id) Repo.get!(Link, id)
end end
def get_one_by!(attrs) do
Repo.get_by!(Link, attrs)
end
def get_one_by_shorten!(shorten) do
get_one_by!(%{shorten: shorten})
end
def get_one_by(attrs) do def get_one_by(attrs) do
Repo.get_by(Link, attrs) Repo.get_by(Link, attrs)
end end
@ -41,9 +70,15 @@ defmodule LinkShortener.Links do
|> Repo.all() |> Repo.all()
end end
def edit_one(%Link{} = link) do def get_all_by_user(%User{id: user_id} = user) do
from(Link)
|> where([l], l.user_id == ^user_id)
|> Repo.all()
end
def edit_one(%Link{} = link, attrs \\ %{}) do
link link
|> Link.changeset(%{}) |> Link.changeset(attrs)
end end
def update_one(%Link{} = link, changes) do def update_one(%Link{} = link, changes) do

@ -1,53 +1,60 @@
defmodule LinkShortenerWeb do defmodule LinkShortenerWeb do
@moduledoc """ @moduledoc """
The entrypoint for defining your web interface, such The entrypoint for defining your web interface, such
as controllers, views, channels and so on. as controllers, components, channels, and so on.
This can be used in your application as: This can be used in your application as:
use LinkShortenerWeb, :controller use LinkShortenerWeb, :controller
use LinkShortenerWeb, :view use LinkShortenerWeb, :html
The definitions below will be executed for every view, The definitions below will be executed for every controller,
controller, etc, so keep them short and clean, focused component, etc, so keep them short and clean, focused
on imports, uses and aliases. on imports, uses and aliases.
Do NOT define functions inside the quoted expressions Do NOT define functions inside the quoted expressions
below. Instead, define any helper function in modules below. Instead, define additional modules and import
and import those modules here. those modules here.
""" """
def controller do def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do quote do
use Phoenix.Controller, namespace: LinkShortenerWeb use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn import Plug.Conn
import LinkShortenerWeb.Gettext import Phoenix.Controller
alias LinkShortenerWeb.Router.Helpers, as: Routes import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
end end
end end
def view do def controller do
quote do quote do
use Phoenix.View, use Phoenix.Controller,
root: "lib/link_shortener_web/templates", formats: [:html, :json],
namespace: LinkShortenerWeb layouts: [html: LinkShortenerWeb.Layouts]
# Import convenience functions from controllers import Plug.Conn
import Phoenix.Controller, import LinkShortenerWeb.Gettext
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
# Include shared imports and aliases for views unquote(verified_routes())
unquote(view_helpers())
end end
end end
def live_view do def live_view do
quote do quote do
use Phoenix.LiveView, use Phoenix.LiveView,
layout: {LinkShortenerWeb.LayoutView, "live.html"} layout: {LinkShortenerWeb.Layouts, :app}
unquote(view_helpers()) unquote(html_helpers())
end end
end end
@ -55,54 +62,50 @@ defmodule LinkShortenerWeb do
quote do quote do
use Phoenix.LiveComponent use Phoenix.LiveComponent
unquote(view_helpers()) unquote(html_helpers())
end end
end end
def component do def html do
quote do quote do
use Phoenix.Component use Phoenix.Component
unquote(view_helpers()) # Import convenience functions from controllers
end import Phoenix.Controller,
end only: [get_csrf_token: 0, view_module: 1, view_template: 1]
def router do
quote do
use Phoenix.Router
import Plug.Conn # Include general helpers for rendering HTML
import Phoenix.Controller unquote(html_helpers())
import Phoenix.LiveView.Router
end end
end end
def channel do defp html_helpers do
quote do quote do
use Phoenix.Channel # HTML escaping functionality
import Phoenix.HTML
# Core UI components and translation
import LinkShortenerWeb.CoreComponents
import LinkShortenerWeb.Gettext import LinkShortenerWeb.Gettext
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS
# Routes generation with the ~p sigil
unquote(verified_routes())
end end
end end
defp view_helpers do def verified_routes do
quote do quote do
# Use all HTML functionality (forms, tags, etc) use Phoenix.VerifiedRoutes,
use Phoenix.HTML endpoint: LinkShortenerWeb.Endpoint,
router: LinkShortenerWeb.Router,
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) statics: LinkShortenerWeb.static_paths()
import Phoenix.LiveView.Helpers
# Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View
import LinkShortenerWeb.ErrorHelpers
import LinkShortenerWeb.Gettext
alias LinkShortenerWeb.Router.Helpers, as: Routes
end end
end end
@doc """ @doc """
When used, dispatch to the appropriate controller/view/etc. When used, dispatch to the appropriate controller/live_view/etc.
""" """
defmacro __using__(which) when is_atom(which) do defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, []) apply(__MODULE__, which, [])

@ -8,3 +8,4 @@ defmodule LinkShortenerWeb.Auth.ErrorHandler do
|> send_resp(401, body) |> send_resp(401, body)
end end
end end

@ -5,14 +5,14 @@ defmodule LinkShortenerWeb.Auth.Guardian do
alias LinkShortener.Accounts.User alias LinkShortener.Accounts.User
def subject_for_token(user, _claims) do def subject_for_token(user, _claims) do
sub = to_string(user.id) {:ok, to_string(user.id)}
{:ok, sub}
end end
def resource_from_claims(claims) do def resource_from_claims(%{"sub" => id}) do
id = claims["sub"] user = Accounts.get_user!(id)
resource = Accounts.get_user!(id) {:ok, user}
{:ok, resource} rescue
Ecto.NoResultsError -> {:error, :resource_not_found}
end end
def authenticate(email, password) do def authenticate(email, password) do
@ -28,4 +28,28 @@ defmodule LinkShortenerWeb.Auth.Guardian do
{:ok, token, _claims} = encode_and_sign(user) {:ok, token, _claims} = encode_and_sign(user)
{:ok, user, token} {:ok, user, token}
end end
def after_encode_and_sign(resource, claims, token, _options) do
with {:ok, _} <- Guardian.DB.after_encode_and_sign(resource, claims["typ"], claims, token) do
{:ok, token}
end
end
def on_verify(claims, token, _options) do
with {:ok, _} <- Guardian.DB.on_verify(claims, token) do
{:ok, claims}
end
end
def on_refresh({old_token, old_claims}, {new_token, new_claims}, _options) do
with {:ok, _, _} <- Guardian.DB.on_refresh({old_token, old_claims}, {new_token, new_claims}) do
{:ok, {old_token, old_claims}, {new_token, new_claims}}
end
end
def on_revoke(claims, token, _options) do
with {:ok, _} <- Guardian.DB.on_revoke(claims, token) do
{:ok, claims}
end
end
end end

@ -0,0 +1,676 @@
defmodule LinkShortenerWeb.CoreComponents do
@moduledoc """
Provides core UI components.
At first glance, this module may seem daunting, but its goal is to provide
core building blocks for your application, such as modals, tables, and
forms. The components consist mostly of markup and are well-documented
with doc strings and declarative assigns. You may customize and style
them in any way you want, based on your application growth and needs.
The default components use Tailwind CSS, a utility-first CSS framework.
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
how to customize them or feel free to swap in another framework altogether.
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
"""
use Phoenix.Component
alias Phoenix.LiveView.JS
import LinkShortenerWeb.Gettext
@doc """
Renders a modal.
## Examples
<.modal id="confirm-modal">
This is a modal.
</.modal>
JS commands may be passed to the `:on_cancel` to configure
the closing/cancel event, for example:
<.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
This is another modal.
</.modal>
"""
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
slot :inner_block, required: true
def modal(assigns) do
~H"""
<div
id={@id}
phx-mounted={@show && show_modal(@id)}
phx-remove={hide_modal(@id)}
data-cancel={JS.exec(@on_cancel, "phx-remove")}
class="relative z-50 hidden"
>
<div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
<div
class="fixed inset-0 overflow-y-auto"
aria-labelledby={"#{@id}-title"}
aria-describedby={"#{@id}-description"}
role="dialog"
aria-modal="true"
tabindex="0"
>
<div class="flex min-h-full items-center justify-center">
<div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
<.focus_wrap
id={"#{@id}-container"}
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
phx-key="escape"
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
>
<div class="absolute top-6 right-5">
<button
phx-click={JS.exec("data-cancel", to: "##{@id}")}
type="button"
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
aria-label={gettext("close")}
>
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
</button>
</div>
<div id={"#{@id}-content"}>
<%= render_slot(@inner_block) %>
</div>
</.focus_wrap>
</div>
</div>
</div>
</div>
"""
end
@doc """
Renders flash notices.
## Examples
<.flash kind={:info} flash={@flash} />
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
"""
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class={[
"fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
@kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
]}
{@rest}
>
<p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
<%= @title %>
</p>
<p class="mt-2 text-sm leading-5"><%= msg %></p>
<button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}>
<.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id}>
<.flash kind={:info} title={gettext("Success!")} flash={@flash} />
<.flash kind={:error} title={gettext("Error!")} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error")}
phx-connected={hide("#client-error")}
hidden
>
<%= gettext("Attempting to reconnect") %>
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error")}
phx-connected={hide("#server-error")}
hidden
>
<%= gettext("Hang in there while we get back on track") %>
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
</.flash>
</div>
"""
end
@doc """
Renders a simple form.
## Examples
<.simple_form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label="Email"/>
<.input field={@form[:username]} label="Username" />
<:actions>
<.button>Save</.button>
</:actions>
</.simple_form>
"""
attr :for, :any, required: true, doc: "the data structure for the form"
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
attr :rest, :global,
include: ~w(autocomplete name rel action enctype method novalidate target multipart),
doc: "the arbitrary HTML attributes to apply to the form tag"
slot :inner_block, required: true
slot :actions, doc: "the slot for form actions, such as a submit button"
def simple_form(assigns) do
~H"""
<.form :let={f} for={@for} as={@as} {@rest}>
<div class="mt-10 space-y-8 bg-white">
<%= render_slot(@inner_block, f) %>
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
<%= render_slot(action, f) %>
</div>
</div>
</.form>
"""
end
@doc """
Renders a button.
## Examples
<.button>Send!</.button>
<.button phx-click="go" class="ml-2">Send!</.button>
"""
attr :type, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global, include: ~w(disabled form name value)
slot :inner_block, required: true
def button(assigns) do
~H"""
<button
type={@type}
class={[
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
"text-sm font-semibold leading-6 text-white active:text-white/80",
@class
]}
{@rest}
>
<%= render_slot(@inner_block) %>
</button>
"""
end
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `<select>` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information. Unsupported types, such as hidden and radio,
are best written directly in your templates.
## Examples
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
range search select tel text textarea time url week)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div>
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
{@rest}
/>
<%= @label %>
</label>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div>
<.label for={@id}><%= @label %></.label>
<select
id={@id}
name={@name}
class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value=""><%= @prompt %></option>
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
</select>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div>
<.label for={@id}><%= @label %></.label>
<textarea
id={@id}
name={@name}
class={[
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]",
@errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div>
<.label for={@id}><%= @label %></.label>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
@errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
/>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
@doc """
Renders a label.
"""
attr :for, :string, default: nil
slot :inner_block, required: true
def label(assigns) do
~H"""
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
<%= render_slot(@inner_block) %>
</label>
"""
end
@doc """
Generates a generic error message.
"""
slot :inner_block, required: true
def error(assigns) do
~H"""
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600">
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
<%= render_slot(@inner_block) %>
</p>
"""
end
@doc """
Renders a header with title.
"""
attr :class, :string, default: nil
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
<div>
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
<%= render_slot(@inner_block) %>
</h1>
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
<%= render_slot(@subtitle) %>
</p>
</div>
<div class="flex-none"><%= render_slot(@actions) %></div>
</header>
"""
end
@doc ~S"""
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id"><%= user.id %></:col>
<:col :let={user} label="username"><%= user.username %></:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
<table class="w-[40rem] mt-11 sm:w-full">
<thead class="text-sm text-left leading-6 text-zinc-500">
<tr>
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
<th :if={@action != []} class="relative p-0 pb-4">
<span class="sr-only"><%= gettext("Actions") %></span>
</th>
</tr>
</thead>
<tbody
id={@id}
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
<td
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={["relative p-0", @row_click && "hover:cursor-pointer"]}
>
<div class="block py-4 pr-6">
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
<%= render_slot(col, @row_item.(row)) %>
</span>
</div>
</td>
<td :if={@action != []} class="relative w-14 p-0">
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
<span
:for={action <- @action}
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<%= render_slot(action, @row_item.(row)) %>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
@doc """
Renders a data list.
## Examples
<.list>
<:item title="Title"><%= @post.title %></:item>
<:item title="Views"><%= @post.views %></:item>
</.list>
"""
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<div class="mt-14">
<dl class="-my-4 divide-y divide-zinc-100">
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
<dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt>
<dd class="text-zinc-700"><%= render_slot(item) %></dd>
</div>
</dl>
</div>
"""
end
@doc """
Renders a back navigation link.
## Examples
<.back navigate={~p"/posts"}>Back to posts</.back>
"""
attr :navigate, :any, required: true
slot :inner_block, required: true
def back(assigns) do
~H"""
<div class="mt-16">
<.link
navigate={@navigate}
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
<%= render_slot(@inner_block) %>
</.link>
</div>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).
Heroicons come in three styles outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
You can customize the size and colors of the icons by setting
width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled within
your compiled app.css by the plugin in your `assets/tailwind.config.js`.
## Examples
<.icon name="hero-x-mark-solid" />
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
"""
attr :name, :string, required: true
attr :class, :string, default: nil
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all transform ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all transform ease-in duration-200",
"opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
def show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.show(to: "##{id}")
|> JS.show(
to: "##{id}-bg",
time: 300,
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
)
|> show("##{id}-container")
|> JS.add_class("overflow-hidden", to: "body")
|> JS.focus_first(to: "##{id}-content")
end
def hide_modal(js \\ %JS{}, id) do
js
|> JS.hide(
to: "##{id}-bg",
transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
)
|> hide("##{id}-container")
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|> JS.remove_class("overflow-hidden", to: "body")
|> JS.pop_focus()
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# However the error messages in our forms and APIs are generated
# dynamically, so we need to translate them by calling Gettext
# with our gettext backend as first argument. Translations are
# available in the errors.po file (as we use the "errors" domain).
if count = opts[:count] do
Gettext.dngettext(LinkShortenerWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(LinkShortenerWeb.Gettext, "errors", msg, opts)
end
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end

@ -0,0 +1,14 @@
defmodule LinkShortenerWeb.Layouts do
@moduledoc """
This module holds different layouts used by your application.
See the `layouts` directory for all templates available.
The "root" layout is a skeleton rendered as part of the
application router. The "app" layout is set as the default
layout on both `use LinkShortenerWeb, :controller` and
`use LinkShortenerWeb, :live_view`.
"""
use LinkShortenerWeb, :html
embed_templates "layouts/*"
end

@ -0,0 +1,23 @@
<header class="px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between border-b border-zinc-100 py-3 text-sm">
<div class="flex items-center gap-4">
<a href="/">
<img src={~p"/images/logo.svg"} width="36" />
</a>
<a href="/" class="text-brand flex items-center text-sm font-semibold leading-6">
LinkShortener
</a>
</div>
<div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900">
<a href="/links" class="hover:text-zinc-700">
Links
</a>
</div>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl">
<.flash_group flash={@flash} />
<%= @inner_content %>
</div>
</main>

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en" class="[scrollbar-gutter:stable]">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title suffix=" · Phoenix Framework">
<%= assigns[:page_title] || "LinkShortener" %>
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
</head>
<body class="bg-white">
<ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
<%= if @current_user do %>
<li class="text-[0.8125rem] leading-6 text-zinc-900">
<%= @current_user.email %>
</li>
<li>
<.link
href={~p"/users/settings"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Settings
</.link>
</li>
<li>
<.link
href={~p"/users/log_out"}
method="delete"
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Log out
</.link>
</li>
<% else %>
<li>
<.link
href={~p"/users/register"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Register
</.link>
</li>
<li>
<.link
href={~p"/users/log_in"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Log in
</.link>
</li>
<% end %>
</ul>
<%= @inner_content %>
</body>
</html>

@ -1,4 +1,4 @@
defmodule LinkShortenerWeb.Api.V1.UserController do defmodule LinkShortenerWeb.Api.V1.AccountsController do
use LinkShortenerWeb, :controller use LinkShortenerWeb, :controller
alias LinkShortener.Accounts alias LinkShortener.Accounts
@ -7,20 +7,28 @@ defmodule LinkShortenerWeb.Api.V1.UserController do
action_fallback LinkShortenerWeb.FallbackController action_fallback LinkShortenerWeb.FallbackController
def create(conn, %{"user" => user_params}) do def sign_up(conn, %{"user" => user_params}) do
with {:ok, %User{} = user} <- Accounts.register_user(user_params), with {:ok, %User{} = user} <- Accounts.register_user(user_params),
{:ok, token, _claims} <- Guardian.encode_and_sign(user) do {:ok, token, _claims} <- Guardian.encode_and_sign(user) do
conn conn
|> put_status(:created) |> put_status(:created)
|> render("user.json", %{user: user, token: token}) |> render(:user, %{user: user, token: token})
end end
end end
def signin(conn, %{"email" => email, "password" => password}) do def sign_in(conn, %{"email" => email, "password" => password}) do
with {:ok, user, token} <- Guardian.authenticate(email, password) do with {:ok, user, token} <- Guardian.authenticate(email, password) do
conn conn
|> put_status(:created) |> put_status(:created)
|> render("user.json", %{user: user, token: token}) |> render(:user, %{user: user, token: token})
end end
end end
def sign_out(conn, %{}) do
token = Guardian.Plug.current_token(conn)
Guardian.revoke(token)
conn
|> put_status(:ok)
|> render(:sign_out, %{token: token})
end
end end

@ -0,0 +1,17 @@
defmodule LinkShortenerWeb.Api.V1.AccountsJSON do
alias LinkShortener.Links.Link
def user(%{user: user, token: token}) do
%{
email: user.email,
token: token
}
end
def sign_out(%{token: token}) do
%{
message: "Successfully sign out",
token: token
}
end
end

@ -8,28 +8,34 @@ defmodule LinkShortenerWeb.Api.V1.LinkController do
def index(conn, _params) do def index(conn, _params) do
links = Links.get_all() links = Links.get_all()
render(conn, "index.json", links: links) render(conn, :index, links: links)
end end
def create(conn, %{"link" => link_params}) do def create(conn, %{"link" => link_params}) do
user = Guardian.Plug.current_resource(conn)
link_params =
link_params
|> Map.put("user_id", user.id)
with {:ok, %Link{} = link} <- Links.insert_one(link_params) do with {:ok, %Link{} = link} <- Links.insert_one(link_params) do
conn conn
|> put_status(:created) |> put_status(:created)
|> put_resp_header("location", Routes.v1_link_path(conn, :show, link)) |> put_resp_header("location", ~p"/api/v1/items/")
|> render("show.json", link: link) |> render(:show, link: link)
end end
end end
def show(conn, %{"id" => id}) do def show(conn, %{"id" => id}) do
link = Links.get_one!(id) link = Links.get_one!(id)
render(conn, "show.json", link: link) render(conn, :show, link: link)
end end
def update(conn, %{"id" => id, "link" => link_params}) do def update(conn, %{"id" => id, "link" => link_params}) do
link = Links.get_one!(id) link = Links.get_one!(id)
with {:ok, %Link{} = link} <- Links.update_one(link, link_params) do with {:ok, %Link{} = link} <- Links.update_one(link, link_params) do
render(conn, "show.json", link: link) render(conn, :show, link: link)
end end
end end

@ -0,0 +1,20 @@
defmodule LinkShortenerWeb.Api.V1.LinkJSON do
alias LinkShortener.Links.Link
def index(%{links: links}) do
%{data: for(link <- links, do: link(link))}
end
def show(%{link: link}) do
%{data: link(link)}
end
def link(%Link{} = link) do
%{
id: link.id,
name: link.name,
url: link.url,
shorten: link.shorten
}
end
end

@ -0,0 +1,25 @@
defmodule LinkShortenerWeb.ChangesetJSON do
@doc """
Renders changeset errors.
"""
def error(%{changeset: changeset}) do
# When encoded, the changeset returns its errors
# as a JSON object. So we just pass it forward.
%{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
end
defp translate_error({msg, opts}) do
# You can make use of gettext to translate error messages by
# uncommenting and adjusting the following code:
# if count = opts[:count] do
# Gettext.dngettext(LinkShortenerWeb.Gettext, "errors", msg, msg, count, opts)
# else
# Gettext.dgettext(LinkShortenerWeb.Gettext, "errors", msg, opts)
# end
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
end

@ -0,0 +1,24 @@
defmodule LinkShortenerWeb.ErrorHTML do
@moduledoc """
This module is invoked by your endpoint in case of errors on HTML requests.
See config/config.exs.
"""
use LinkShortenerWeb, :html
# If you want to customize your error pages,
# uncomment the embed_templates/1 call below
# and add pages to the error directory:
#
# * lib/link_shortener_web/controllers/error_html/404.html.heex
# * lib/link_shortener_web/controllers/error_html/500.html.heex
#
# embed_templates "error_html/*"
# The default is to render a plain text page based on
# the template name. For example, "404.html" becomes
# "Not Found".
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end

@ -0,0 +1,21 @@
defmodule LinkShortenerWeb.ErrorJSON do
@moduledoc """
This module is invoked by your endpoint in case of errors on JSON requests.
See config/config.exs.
"""
# If you want to customize a particular status code,
# you may add your own clauses, such as:
#
# def render("500.json", _assigns) do
# %{errors: %{detail: "Internal Server Error"}}
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.json" becomes
# "Not Found".
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end

@ -10,21 +10,22 @@ defmodule LinkShortenerWeb.FallbackController do
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn conn
|> put_status(:unprocessable_entity) |> put_status(:unprocessable_entity)
|> put_view(LinkShortenerWeb.ChangesetView) |> put_view(json: LinkShortenerWeb.ChangesetJSON)
|> render("error.json", changeset: changeset) |> render(:error, changeset: changeset)
end end
# This clause is an example of how to handle resources that cannot be found. # This clause is an example of how to handle resources that cannot be found.
def call(conn, {:error, :not_found}) do def call(conn, {:error, :not_found}) do
conn conn
|> put_status(:not_found) |> put_status(:not_found)
|> put_view(LinkShortenerWeb.ErrorView) |> put_view(html: LinkShortenerWeb.ErrorHTML, json: LinkShortenerWeb.ErrorJSON)
|> render(:"404") |> render(:"404")
end end
def call(conn, {:error, :unauthorized}) do def call(conn, {:error, :unauthorized}) do
conn conn
|> put_status(:unauthorized) |> put_status(:unauthorized)
|> render(LinkShortenerWeb.ErrorView, :"401") |> put_view(html: LinkShortenerWeb.ErrorHTML, json: LinkShortenerWeb.ErrorJSON)
|> render(:"401")
end end
end end

@ -1,7 +1,9 @@
defmodule LinkShortenerWeb.PageController do defmodule LinkShortenerWeb.PageController do
use LinkShortenerWeb, :controller use LinkShortenerWeb, :controller
def index(conn, _params) do def home(conn, _params) do
render(conn, "index.html") # The home page is often custom made,
# so skip the default app layout.
render(conn, :home, layout: false)
end end
end end

@ -0,0 +1,10 @@
defmodule LinkShortenerWeb.PageHTML do
@moduledoc """
This module contains pages rendered by PageController.
See the `page_html` directory for all templates available.
"""
use LinkShortenerWeb, :html
embed_templates "page_html/*"
end

@ -0,0 +1,222 @@
<.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>
<h1 class="text-brand mt-10 flex items-center text-sm font-semibold leading-6">
Phoenix Framework
<small class="bg-brand/5 text-[0.8125rem] ml-3 rounded-full px-2 font-medium leading-6">
v<%= Application.spec(:phoenix, :vsn) %>
</small>
</h1>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-zinc-900 text-balance">
Peace of mind from prototype to production.
</p>
<p class="mt-4 text-base leading-7 text-zinc-600">
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
</p>
<div class="flex">
<div class="w-full sm:w-auto">
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
<a
href="https://hexdocs.pm/phoenix/overview.html"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path d="m12 4 10-2v18l-10 2V4Z" fill="#18181B" fill-opacity=".15" />
<path
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
fill="#18181B"
/>
</svg>
Source Code
</span>
</a>
<a
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="#18181B"
fill-opacity=".15"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div>
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-zinc-700 sm:grid-cols-2">
<div>
<a
href="https://twitter.com/elixirphoenix"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M5.403 14c5.283 0 8.172-4.617 8.172-8.62 0-.131 0-.262-.008-.391A6.033 6.033 0 0 0 15 3.419a5.503 5.503 0 0 1-1.65.477 3.018 3.018 0 0 0 1.263-1.676 5.579 5.579 0 0 1-1.824.736 2.832 2.832 0 0 0-1.63-.916 2.746 2.746 0 0 0-1.821.319A2.973 2.973 0 0 0 8.076 3.78a3.185 3.185 0 0 0-.182 1.938 7.826 7.826 0 0 1-3.279-.918 8.253 8.253 0 0 1-2.64-2.247 3.176 3.176 0 0 0-.315 2.208 3.037 3.037 0 0 0 1.203 1.836A2.739 2.739 0 0 1 1.56 6.22v.038c0 .7.23 1.377.65 1.919.42.54 1.004.912 1.654 1.05-.423.122-.866.14-1.297.052.184.602.541 1.129 1.022 1.506a2.78 2.78 0 0 0 1.662.598 5.656 5.656 0 0 1-2.007 1.074A5.475 5.475 0 0 1 1 12.64a7.827 7.827 0 0 0 4.403 1.358" />
</svg>
Follow on Twitter
</a>
</div>
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir Forum
</a>
</div>
<div>
<a
href="https://web.libera.chat/#elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.356 2.007a.75.75 0 0 1 .637.849l-1.5 10.5a.75.75 0 1 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.637ZM11.356 2.008a.75.75 0 0 1 .637.848l-1.5 10.5a.75.75 0 0 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.636Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14 5.25a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75ZM13 10.75a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75Z"
/>
</svg>
Chat on Libera IRC
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://fly.io/docs/elixir/getting-started/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div>
</div>
</div>
</div>
</div>

@ -1,9 +1,10 @@
defmodule LinkShortenerWeb.RedirectController do defmodule LinkShortenerWeb.RedirectionController do
use LinkShortenerWeb, :controller use LinkShortenerWeb, :controller
alias LinkShortener.Links alias LinkShortener.Links
def show(conn, %{"shorten" => shorten}) do def show(conn, %{"shorten" => shorten}) do
%{url: url} = Links.get_one_by_shorten(shorten) %{url: url} = Links.get_one_by_shorten!(shorten)
redirect(conn, external: url) redirect(conn, external: url)
end end
end end

@ -1,56 +0,0 @@
defmodule LinkShortenerWeb.UserConfirmationController do
use LinkShortenerWeb, :controller
alias LinkShortener.Accounts
def new(conn, _params) do
render(conn, "new.html")
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(conn, :edit, &1)
)
end
conn
|> put_flash(
:info,
"If your email is in our system and it has not been confirmed yet, " <>
"you will receive an email with instructions shortly."
)
|> redirect(to: "/")
end
def edit(conn, %{"token" => token}) do
render(conn, "edit.html", token: token)
end
# Do not log in the user after confirmation to avoid a
# leaked token giving the user access to the account.
def update(conn, %{"token" => token}) do
case Accounts.confirm_user(token) do
{:ok, _} ->
conn
|> put_flash(:info, "User confirmed successfully.")
|> redirect(to: "/")
:error ->
# If there is a current user and the account was already confirmed,
# then odds are that the confirmation link was already visited, either
# by some automation or by the user themselves, so we redirect without
# a warning message.
case conn.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
redirect(conn, to: "/")
%{} ->
conn
|> put_flash(:error, "User confirmation link is invalid or it has expired.")
|> redirect(to: "/")
end
end
end
end

@ -1,30 +0,0 @@
defmodule LinkShortenerWeb.UserRegistrationController do
use LinkShortenerWeb, :controller
alias LinkShortener.Accounts
alias LinkShortener.Accounts.User
alias LinkShortenerWeb.UserAuth
def new(conn, _params) do
changeset = Accounts.change_user_registration(%User{})
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(conn, :edit, &1)
)
conn
|> put_flash(:info, "User created successfully.")
|> UserAuth.log_in_user(user)
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
end

@ -1,58 +0,0 @@
defmodule LinkShortenerWeb.UserResetPasswordController do
use LinkShortenerWeb, :controller
alias LinkShortener.Accounts
plug :get_user_by_reset_password_token when action in [:edit, :update]
def new(conn, _params) do
render(conn, "new.html")
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
&Routes.user_reset_password_url(conn, :edit, &1)
)
end
conn
|> put_flash(
:info,
"If your email is in our system, you will receive instructions to reset your password shortly."
)
|> redirect(to: "/")
end
def edit(conn, _params) do
render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user))
end
# Do not log in the user after reset password to avoid a
# leaked token giving the user access to the account.
def update(conn, %{"user" => user_params}) do
case Accounts.reset_user_password(conn.assigns.user, user_params) do
{:ok, _} ->
conn
|> put_flash(:info, "Password reset successfully.")
|> redirect(to: Routes.user_session_path(conn, :new))
{:error, changeset} ->
render(conn, "edit.html", changeset: changeset)
end
end
defp get_user_by_reset_password_token(conn, _opts) do
%{"token" => token} = conn.params
if user = Accounts.get_user_by_reset_password_token(token) do
conn |> assign(:user, user) |> assign(:token, token)
else
conn
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|> redirect(to: "/")
|> halt()
end
end
end

@ -4,18 +4,33 @@ defmodule LinkShortenerWeb.UserSessionController do
alias LinkShortener.Accounts alias LinkShortener.Accounts
alias LinkShortenerWeb.UserAuth alias LinkShortenerWeb.UserAuth
def new(conn, _params) do def create(conn, %{"_action" => "registered"} = params) do
render(conn, "new.html", error_message: nil) create(conn, params, "Account created successfully!")
end end
def create(conn, %{"user" => user_params}) do def create(conn, %{"_action" => "password_updated"} = params) do
conn
|> put_session(:user_return_to, ~p"/users/settings")
|> create(params, "Password updated successfully!")
end
def create(conn, params) do
create(conn, params, "Welcome back!")
end
defp create(conn, %{"user" => user_params}, info) do
%{"email" => email, "password" => password} = user_params %{"email" => email, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, password) do if user = Accounts.get_user_by_email_and_password(email, password) do
UserAuth.log_in_user(conn, user, user_params) conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
else else
# In order to prevent user enumeration attacks, don't disclose whether the email is registered. # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
render(conn, "new.html", error_message: "Invalid email or password") conn
|> put_flash(:error, "Invalid email or password")
|> put_flash(:email, String.slice(email, 0, 160))
|> redirect(to: ~p"/users/log_in")
end end
end end

@ -1,74 +0,0 @@
defmodule LinkShortenerWeb.UserSettingsController do
use LinkShortenerWeb, :controller
alias LinkShortener.Accounts
alias LinkShortenerWeb.UserAuth
plug :assign_email_and_password_changesets
def edit(conn, _params) do
render(conn, "edit.html")
end
def update(conn, %{"action" => "update_email"} = params) do
%{"current_password" => password, "user" => user_params} = params
user = conn.assigns.current_user
case Accounts.apply_user_email(user, password, user_params) do
{:ok, applied_user} ->
Accounts.deliver_update_email_instructions(
applied_user,
user.email,
&Routes.user_settings_url(conn, :confirm_email, &1)
)
conn
|> put_flash(
:info,
"A link to confirm your email change has been sent to the new address."
)
|> redirect(to: Routes.user_settings_path(conn, :edit))
{:error, changeset} ->
render(conn, "edit.html", email_changeset: changeset)
end
end
def update(conn, %{"action" => "update_password"} = params) do
%{"current_password" => password, "user" => user_params} = params
user = conn.assigns.current_user
case Accounts.update_user_password(user, password, user_params) do
{:ok, user} ->
conn
|> put_flash(:info, "Password updated successfully.")
|> put_session(:user_return_to, Routes.user_settings_path(conn, :edit))
|> UserAuth.log_in_user(user)
{:error, changeset} ->
render(conn, "edit.html", password_changeset: changeset)
end
end
def confirm_email(conn, %{"token" => token}) do
case Accounts.update_user_email(conn.assigns.current_user, token) do
:ok ->
conn
|> put_flash(:info, "Email changed successfully.")
|> redirect(to: Routes.user_settings_path(conn, :edit))
:error ->
conn
|> put_flash(:error, "Email change link is invalid or it has expired.")
|> redirect(to: Routes.user_settings_path(conn, :edit))
end
end
defp assign_email_and_password_changesets(conn, _opts) do
user = conn.assigns.current_user
conn
|> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user))
end
end

@ -7,10 +7,13 @@ defmodule LinkShortenerWeb.Endpoint do
@session_options [ @session_options [
store: :cookie, store: :cookie,
key: "_link_shortener_key", key: "_link_shortener_key",
signing_salt: "O0shzZgl" signing_salt: "dEcSzKxO",
same_site: "Lax"
] ]
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [:user_agent, session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory. # Serve at "/" the static files from "priv/static" directory.
# #
@ -20,7 +23,7 @@ defmodule LinkShortenerWeb.Endpoint do
at: "/", at: "/",
from: :link_shortener, from: :link_shortener,
gzip: false, gzip: false,
only: ~w(assets fonts images favicon.ico robots.txt) only: LinkShortenerWeb.static_paths()
# 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.

@ -0,0 +1,146 @@
defmodule LinkShortenerWeb.LinkLive.FormComponent do
use LinkShortenerWeb, :live_component
alias LinkShortener.Links
attr :is_checked, :boolean, default: true
@impl true
def render(assigns) do
~H"""
<div>
<.header>
<%= @title %>
<:subtitle>Use this form to manage link records.</:subtitle>
</.header>
<.simple_form
for={@form}
id="link-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:name]} type="text" label="Name" placeholder="Enter a name here" />
<.input field={@form[:url]} type="text" label="Url" placeholder="Enter an url here" />
<%= if @is_checked and @action == :new do %>
<.label>Shorten</.label>
<% end %>
<%= unless @is_checked and @action != :edit do %>
<.input
field={@form[:shorten]}
type="text"
label="Shorten"
placeholder="Enter a shorten here"
/>
<% end %>
<%= if @action == :new do %>
<label class="inline-flex items-center cursor-pointer">
<.input
id="random-toggle"
field={@form[:toggle]}
type="checkbox"
label="Random shorten"
checked={@is_checked}
phx-click="toggle"
phx-target={@myself}
phx-value-checked={!@is_checked}
class="peer sr-only"
/>
</label>
<% end %>
<:actions>
<.button phx-disable-with="Saving...">Save Link</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def update(%{link: link} = assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_new(:form, fn ->
to_form(Links.edit_one(link))
end)}
end
@impl true
def handle_event("validate", %{"link" => link_params}, socket) do
link_params = with_user_id(link_params, socket.assigns.current_user)
changeset = Links.edit_one(socket.assigns.link, link_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
@impl true
def handle_event("toggle", params, socket) do
is_checked = Map.get(params, "value", "false") == "true"
{:noreply, assign(socket, :is_checked, is_checked)}
end
def handle_event("save", %{"link" => link_params}, socket) do
save_link(socket, socket.assigns.action, link_params)
end
defp save_link(socket, :edit, link_params) do
case Links.update_one(socket.assigns.link, link_params) do
{:ok, link} ->
notify_parent({:saved, link})
{:noreply,
socket
|> put_flash(:info, "Link updated successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_link(socket, :new, link_params) do
{is_random_string, link_params} = Map.pop(link_params, "toggle")
is_random = is_random_string == "true"
link_params =
link_params
|> with_user_id(socket.assigns.current_user)
case create_link(link_params, is_random) do
{:ok, link} ->
notify_parent({:saved, link})
{:noreply,
socket
|> put_flash(:info, "Link created successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp create_link(link_params, is_shorten_random) do
if is_shorten_random do
Links.create_one(link_params, is_atom_based: false)
else
Links.insert_one(link_params)
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp with_user_id(params, current_user) do
if Map.has_key?(params, "user_id") do
params
else
params
|> Map.put("user_id", current_user.id)
end
end
end

@ -0,0 +1,53 @@
defmodule LinkShortenerWeb.LinkLive.Index do
use LinkShortenerWeb, :live_view
alias LinkShortener.Links
alias LinkShortener.Links.Link
@impl true
def mount(_params, _session, socket) do
current_user = socket.assigns.current_user
is_random_shorten_by_default = true
{:ok,
socket
|> stream(:links, Links.get_all_by_user(current_user))
|> assign(:is_random_shorten_by_default, is_random_shorten_by_default)}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit Link")
|> assign(:link, Links.get_one!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Link")
|> assign(:link, %Link{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Links")
|> assign(:link, nil)
end
@impl true
def handle_info({LinkShortenerWeb.LinkLive.FormComponent, {:saved, link}}, socket) do
{:noreply, stream_insert(socket, :links, link)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
link = Links.get_one!(id)
{:ok, _} = Links.delete_one(link)
{:noreply, stream_delete(socket, :links, link)}
end
end

@ -0,0 +1,45 @@
<.header>
Listing Links
<:actions>
<.link patch={~p"/links/new"}>
<.button>New Link</.button>
</.link>
</:actions>
</.header>
<.table
id="links"
rows={@streams.links}
row_click={fn {_id, link} -> JS.navigate(~p"/links/#{link}") end}
>
<:col :let={{_id, link}} label="Name"><%= link.name %></:col>
<:col :let={{_id, link}} label="Url"><%= link.url %></:col>
<:col :let={{_id, link}} label="Shorten"><%= link.shorten %></:col>
<:action :let={{_id, link}}>
<div class="sr-only">
<.link navigate={~p"/links/#{link}"}>Show</.link>
</div>
<.link patch={~p"/links/#{link}/edit"}>Edit</.link>
</:action>
<:action :let={{id, link}}>
<.link
phx-click={JS.push("delete", value: %{id: link.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
<.modal :if={@live_action in [:new, :edit]} id="link-modal" show on_cancel={JS.patch(~p"/links")}>
<.live_component
module={LinkShortenerWeb.LinkLive.FormComponent}
id={@link.id || :new}
title={@page_title}
action={@live_action}
link={@link}
current_user={@current_user}
is_checked={@is_random_shorten_by_default}
patch={~p"/links"}
/>
</.modal>

@ -0,0 +1,21 @@
defmodule LinkShortenerWeb.LinkLive.Show do
use LinkShortenerWeb, :live_view
alias LinkShortener.Links
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:link, Links.get_one!(id))}
end
defp page_title(:show), do: "Show Link"
defp page_title(:edit), do: "Edit Link"
end

@ -0,0 +1,29 @@
<.header>
Link <%= @link.id %>
<:subtitle>This is a link record.</:subtitle>
<:actions>
<.link patch={~p"/links/#{@link}/show/edit"} phx-click={JS.push_focus()}>
<.button>Edit link</.button>
</.link>
</:actions>
</.header>
<.list>
<:item title="Name"><%= @link.name %></:item>
<:item title="Url"><%= @link.url %></:item>
<:item title="Shorten"><%= @link.shorten %></:item>
</.list>
<.back navigate={~p"/links"}>Back to links</.back>
<.modal :if={@live_action == :edit} id="link-modal" show on_cancel={JS.patch(~p"/links/#{@link}")}>
<.live_component
module={LinkShortenerWeb.LinkLive.FormComponent}
id={@link.id}
title={@page_title}
action={@live_action}
link={@link}
current_user={@current_user}
patch={~p"/links/#{@link}"}
/>
</.modal>

@ -0,0 +1,51 @@
defmodule LinkShortenerWeb.UserConfirmationInstructionsLive do
use LinkShortenerWeb, :live_view
alias LinkShortener.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
No confirmation instructions received?
<:subtitle>We'll send a new confirmation link to your inbox</:subtitle>
</.header>
<.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions">
<.input field={@form[:email]} type="email" placeholder="Email" required />
<:actions>
<.button phx-disable-with="Sending..." class="w-full">
Resend confirmation instructions
</.button>
</:actions>
</.simple_form>
<p class="text-center mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{}, as: "user"))}
end
def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/users/confirm/#{&1}")
)
end
info =
"If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
{:noreply,
socket
|> put_flash(:info, info)
|> redirect(to: ~p"/")}
end
end

@ -0,0 +1,58 @@
defmodule LinkShortenerWeb.UserConfirmationLive do
use LinkShortenerWeb, :live_view
alias LinkShortener.Accounts
def render(%{live_action: :edit} = assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Confirm Account</.header>
<.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<:actions>
<.button phx-disable-with="Confirming..." class="w-full">Confirm my account</.button>
</:actions>
</.simple_form>
<p class="text-center mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(%{"token" => token}, _session, socket) do
form = to_form(%{"token" => token}, as: "user")
{:ok, assign(socket, form: form), temporary_assigns: [form: nil]}
end
# Do not log in the user after confirmation to avoid a
# leaked token giving the user access to the account.
def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do
case Accounts.confirm_user(token) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "User confirmed successfully.")
|> redirect(to: ~p"/")}
:error ->
# If there is a current user and the account was already confirmed,
# then odds are that the confirmation link was already visited, either
# by some automation or by the user themselves, so we redirect without
# a warning message.
case socket.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
{:noreply, redirect(socket, to: ~p"/")}
%{} ->
{:noreply,
socket
|> put_flash(:error, "User confirmation link is invalid or it has expired.")
|> redirect(to: ~p"/")}
end
end
end
end

@ -0,0 +1,50 @@
defmodule LinkShortenerWeb.UserForgotPasswordLive do
use LinkShortenerWeb, :live_view
alias LinkShortener.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Forgot your password?
<:subtitle>We'll send a password reset link to your inbox</:subtitle>
</.header>
<.simple_form for={@form} id="reset_password_form" phx-submit="send_email">
<.input field={@form[:email]} type="email" placeholder="Email" required />
<:actions>
<.button phx-disable-with="Sending..." class="w-full">
Send password reset instructions
</.button>
</:actions>
</.simple_form>
<p class="text-center text-sm mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{}, as: "user"))}
end
def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
&url(~p"/users/reset_password/#{&1}")
)
end
info =
"If your email is in our system, you will receive instructions to reset your password shortly."
{:noreply,
socket
|> put_flash(:info, info)
|> redirect(to: ~p"/")}
end
end

@ -0,0 +1,43 @@
defmodule LinkShortenerWeb.UserLoginLive do
use LinkShortenerWeb, :live_view
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Log in to account
<:subtitle>
Don't have an account?
<.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
Sign up
</.link>
for an account now.
</:subtitle>
</.header>
<.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">
<.input field={@form[:email]} type="email" label="Email" required />
<.input field={@form[:password]} type="password" label="Password" required />
<:actions>
<.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
<.link href={~p"/users/reset_password"} class="text-sm font-semibold">
Forgot your password?
</.link>
</:actions>
<:actions>
<.button phx-disable-with="Logging in..." class="w-full">
Log in <span aria-hidden="true"></span>
</.button>
</:actions>
</.simple_form>
</div>
"""
end
def mount(_params, _session, socket) do
email = Phoenix.Flash.get(socket.assigns.flash, :email)
form = to_form(%{"email" => email}, as: "user")
{:ok, assign(socket, form: form), temporary_assigns: [form: form]}
end
end

@ -0,0 +1,87 @@
defmodule LinkShortenerWeb.UserRegistrationLive do
use LinkShortenerWeb, :live_view
alias LinkShortener.Accounts
alias LinkShortener.Accounts.User
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Register for an account
<:subtitle>
Already registered?
<.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline">
Log in
</.link>
to your account now.
</:subtitle>
</.header>
<.simple_form
for={@form}
id="registration_form"
phx-submit="save"
phx-change="validate"
phx-trigger-action={@trigger_submit}
action={~p"/users/log_in?_action=registered"}
method="post"
>
<.error :if={@check_errors}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={@form[:email]} type="email" label="Email" required />
<.input field={@form[:password]} type="password" label="Password" required />
<:actions>
<.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
</:actions>
</.simple_form>
</div>
"""
end
def mount(_params, _session, socket) do
changeset = Accounts.change_user_registration(%User{})
socket =
socket
|> assign(trigger_submit: false, check_errors: false)
|> assign_form(changeset)
{:ok, socket, temporary_assigns: [form: nil]}
end
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/users/confirm/#{&1}")
)
changeset = Accounts.change_user_registration(user)
{:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
end
end
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_registration(%User{}, user_params)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
form = to_form(changeset, as: "user")
if changeset.valid? do
assign(socket, form: form, check_errors: false)
else
assign(socket, form: form)
end
end
end

@ -0,0 +1,89 @@
defmodule LinkShortenerWeb.UserResetPasswordLive do
use LinkShortenerWeb, :live_view
alias LinkShortener.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Reset Password</.header>
<.simple_form
for={@form}
id="reset_password_form"
phx-submit="reset_password"
phx-change="validate"
>
<.error :if={@form.errors != []}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={@form[:password]} type="password" label="New password" required />
<.input
field={@form[:password_confirmation]}
type="password"
label="Confirm new password"
required
/>
<:actions>
<.button phx-disable-with="Resetting..." class="w-full">Reset Password</.button>
</:actions>
</.simple_form>
<p class="text-center text-sm mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(params, _session, socket) do
socket = assign_user_and_token(socket, params)
form_source =
case socket.assigns do
%{user: user} ->
Accounts.change_user_password(user)
_ ->
%{}
end
{:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]}
end
# Do not log in the user after reset password to avoid a
# leaked token giving the user access to the account.
def handle_event("reset_password", %{"user" => user_params}, socket) do
case Accounts.reset_user_password(socket.assigns.user, user_params) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Password reset successfully.")
|> redirect(to: ~p"/users/log_in")}
{:error, changeset} ->
{:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
end
end
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_password(socket.assigns.user, user_params)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
defp assign_user_and_token(socket, %{"token" => token}) do
if user = Accounts.get_user_by_reset_password_token(token) do
assign(socket, user: user, token: token)
else
socket
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|> redirect(to: ~p"/")
end
end
defp assign_form(socket, %{} = source) do
assign(socket, :form, to_form(source, as: "user"))
end
end

@ -0,0 +1,167 @@
defmodule LinkShortenerWeb.UserSettingsLive do
use LinkShortenerWeb, :live_view
alias LinkShortener.Accounts
def render(assigns) do
~H"""
<.header class="text-center">
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
<div class="space-y-12 divide-y">
<div>
<.simple_form
for={@email_form}
id="email_form"
phx-submit="update_email"
phx-change="validate_email"
>
<.input field={@email_form[:email]} type="email" label="Email" required />
<.input
field={@email_form[:current_password]}
name="current_password"
id="current_password_for_email"
type="password"
label="Current password"
value={@email_form_current_password}
required
/>
<:actions>
<.button phx-disable-with="Changing...">Change Email</.button>
</:actions>
</.simple_form>
</div>
<div>
<.simple_form
for={@password_form}
id="password_form"
action={~p"/users/log_in?_action=password_updated"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<input
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
value={@current_email}
/>
<.input field={@password_form[:password]} type="password" label="New password" required />
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
/>
<.input
field={@password_form[:current_password]}
name="current_password"
type="password"
label="Current password"
id="current_password_for_password"
value={@current_password}
required
/>
<:actions>
<.button phx-disable-with="Changing...">Change Password</.button>
</:actions>
</.simple_form>
</div>
</div>
"""
end
def mount(%{"token" => token}, _session, socket) do
socket =
case Accounts.update_user_email(socket.assigns.current_user, token) do
:ok ->
put_flash(socket, :info, "Email changed successfully.")
:error ->
put_flash(socket, :error, "Email change link is invalid or it has expired.")
end
{:ok, push_navigate(socket, to: ~p"/users/settings")}
end
def mount(_params, _session, socket) do
user = socket.assigns.current_user
email_changeset = Accounts.change_user_email(user)
password_changeset = Accounts.change_user_password(user)
socket =
socket
|> assign(:current_password, nil)
|> assign(:email_form_current_password, nil)
|> assign(:current_email, user.email)
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
{:ok, socket}
end
def handle_event("validate_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
email_form =
socket.assigns.current_user
|> Accounts.change_user_email(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form, email_form_current_password: password)}
end
def handle_event("update_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.apply_user_email(user, password, user_params) do
{:ok, applied_user} ->
Accounts.deliver_user_update_email_instructions(
applied_user,
user.email,
&url(~p"/users/settings/confirm_email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)}
{:error, changeset} ->
{:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))}
end
end
def handle_event("validate_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
password_form =
socket.assigns.current_user
|> Accounts.change_user_password(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form, current_password: password)}
end
def handle_event("update_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.update_user_password(user, password, user_params) do
{:ok, user} ->
password_form =
user
|> Accounts.change_user_password(user_params)
|> to_form()
{:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
{:error, changeset} ->
{:noreply, assign(socket, password_form: to_form(changeset))}
end
end
end

@ -7,7 +7,7 @@ defmodule LinkShortenerWeb.Router 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, {LinkShortenerWeb.LayoutView, :root} plug :put_root_layout, html: {LinkShortenerWeb.Layouts, :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
@ -24,17 +24,16 @@ defmodule LinkShortenerWeb.Router do
scope "/", LinkShortenerWeb do scope "/", LinkShortenerWeb do
pipe_through :browser pipe_through :browser
get "/", PageController, :index get "/", PageController, :home
get "/u/:shorten", RedirectController, :show get "/u/:shorten", RedirectionController, :show
end end
# Other scopes may use custom stacks.
scope "/api", LinkShortenerWeb do scope "/api", LinkShortenerWeb do
pipe_through :api pipe_through :api
scope "/v1", Api.V1, as: :v1 do scope "/v1", Api.V1, as: :v1 do
post "/users/signup", UserController, :create post "/users/sign-up", AccountsController, :sign_up
post "/users/signin", UserController, :signin post "/users/sign-in", AccountsController, :sign_in
end end
end end
@ -42,35 +41,25 @@ defmodule LinkShortenerWeb.Router do
pipe_through [:api, :auth] pipe_through [:api, :auth]
scope "/v1", Api.V1, as: :v1 do scope "/v1", Api.V1, as: :v1 do
post "/users/sign-out", AccountsController, :sign_out
resources "/links", LinkController resources "/links", LinkController
end end
end end
# Enables LiveDashboard only for development # Enable LiveDashboard and Swoosh mailbox preview in development
# if Application.compile_env(:link_shortener, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put # If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it. # it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet, # If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication # you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway). # as long as you are also using SSL (which you should anyway).
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router import Phoenix.LiveDashboard.Router
scope "/" do
pipe_through :browser
live_dashboard "/dashboard", metrics: LinkShortenerWeb.Telemetry
end
end
# Enables the Swoosh mailbox preview in development.
#
# Note that preview only shows emails that were sent by the same
# node running the Phoenix server.
if Mix.env() == :dev do
scope "/dev" do scope "/dev" do
pipe_through :browser pipe_through :browser
live_dashboard "/dashboard", metrics: LinkShortenerWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview forward "/mailbox", Plug.Swoosh.MailboxPreview
end end
end end
@ -80,31 +69,43 @@ defmodule LinkShortenerWeb.Router do
scope "/", LinkShortenerWeb do scope "/", LinkShortenerWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated] pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new live_session :redirect_if_user_is_authenticated,
post "/users/register", UserRegistrationController, :create on_mount: [{LinkShortenerWeb.UserAuth, :redirect_if_user_is_authenticated}] do
get "/users/log_in", UserSessionController, :new live "/users/register", UserRegistrationLive, :new
live "/users/log_in", UserLoginLive, :new
live "/users/reset_password", UserForgotPasswordLive, :new
live "/users/reset_password/:token", UserResetPasswordLive, :edit
end
post "/users/log_in", UserSessionController, :create post "/users/log_in", UserSessionController, :create
get "/users/reset_password", UserResetPasswordController, :new
post "/users/reset_password", UserResetPasswordController, :create
get "/users/reset_password/:token", UserResetPasswordController, :edit
put "/users/reset_password/:token", UserResetPasswordController, :update
end end
scope "/", LinkShortenerWeb do scope "/", LinkShortenerWeb do
pipe_through [:browser, :require_authenticated_user] pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit live_session :require_authenticated_user,
put "/users/settings", UserSettingsController, :update on_mount: [{LinkShortenerWeb.UserAuth, :ensure_authenticated}] do
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
live "/links", LinkLive.Index, :index
live "/links/new", LinkLive.Index, :new
live "/links/:id/edit", LinkLive.Index, :edit
live "/links/:id", LinkLive.Show, :show
live "/links/:id/show/edit", LinkLive.Show, :edit
end
end end
scope "/", LinkShortenerWeb do scope "/", LinkShortenerWeb do
pipe_through [:browser] pipe_through [:browser]
delete "/users/log_out", UserSessionController, :delete delete "/users/log_out", UserSessionController, :delete
get "/users/confirm", UserConfirmationController, :new
post "/users/confirm", UserConfirmationController, :create live_session :current_user,
get "/users/confirm/:token", UserConfirmationController, :edit on_mount: [{LinkShortenerWeb.UserAuth, :mount_current_user}] do
post "/users/confirm/:token", UserConfirmationController, :update live "/users/confirm/:token", UserConfirmationLive, :edit
live "/users/confirm", UserConfirmationInstructionsLive, :new
end
end end
end end

@ -11,9 +11,12 @@ defmodule LinkShortenerWeb.Telemetry do
children = [ children = [
# Telemetry poller will execute the given period measurements # Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000} {:telemetry_poller, measurements: periodic_measurements(), period: 10_000},
# Add reporters as children of your supervision tree. # Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
# to sweep the REST API tokens:
{Guardian.DB.Sweeper, [interval: 60 * 60 * 1000]} # 1 hour
] ]
Supervisor.init(children, strategy: :one_for_one) Supervisor.init(children, strategy: :one_for_one)
@ -22,13 +25,34 @@ defmodule LinkShortenerWeb.Telemetry do
def metrics do def metrics do
[ [
# Phoenix Metrics # Phoenix Metrics
summary("phoenix.endpoint.start.system_time",
unit: {:native, :millisecond}
),
summary("phoenix.endpoint.stop.duration", summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond} unit: {:native, :millisecond}
), ),
summary("phoenix.router_dispatch.start.system_time",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.exception.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration", summary("phoenix.router_dispatch.stop.duration",
tags: [:route], tags: [:route],
unit: {:native, :millisecond} unit: {:native, :millisecond}
), ),
summary("phoenix.socket_connected.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_handled_in.duration",
tags: [:event],
unit: {:native, :millisecond}
),
# Database Metrics # Database Metrics
summary("link_shortener.repo.query.total_time", summary("link_shortener.repo.query.total_time",

@ -1,10 +0,0 @@
<ul>
<%= if @current_user do %>
<li><%= @current_user.email %></li>
<li><%= link "Settings", to: Routes.user_settings_path(@conn, :edit) %></li>
<li><%= link "Log out", to: Routes.user_session_path(@conn, :delete), method: :delete %></li>
<% else %>
<li><%= link "Register", to: Routes.user_registration_path(@conn, :new) %></li>
<li><%= link "Log in", to: Routes.user_session_path(@conn, :new) %></li>
<% end %>
</ul>

@ -1,5 +0,0 @@
<main class="container">
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<%= @inner_content %>
</main>

@ -1,11 +0,0 @@
<main class="container">
<p class="alert alert-info" role="alert"
phx-click="lv:clear-flash"
phx-value-key="info"><%= live_flash(@flash, :info) %></p>
<p class="alert alert-danger" role="alert"
phx-click="lv:clear-flash"
phx-value-key="error"><%= live_flash(@flash, :error) %></p>
<%= @inner_content %>
</main>

@ -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"/>
<meta name="csrf-token" content={csrf_token_value()}>
<%= live_title_tag assigns[:page_title] || "LinkShortener", suffix: " · Phoenix Framework" %>
<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>
</head>
<body>
<header>
<section class="container">
<nav>
<ul>
<%= if function_exported?(Routes, :live_dashboard_path, 2) do %>
<li><%= link "LiveDashboard", to: Routes.live_dashboard_path(@conn, :home) %></li>
<% end %>
</ul>
<%= render "_user_menu.html", assigns %>
</nav>
<a href={Routes.page_path(@conn, :index)} class="phx-logo">
Link Shortener
</a>
</section>
</header>
<%= @inner_content %>
</body>
</html>

@ -1,4 +0,0 @@
<section class="phx-hero">
<h1> Usage </h1>
<p> Go to a url like: "/u/{shorten name}". </p>
</section>

@ -1,12 +0,0 @@
<h1>Confirm account</h1>
<.form let={_f} for={:user} action={Routes.user_confirmation_path(@conn, :update, @token)}>
<div>
<%= submit "Confirm my account" %>
</div>
</.form>
<p>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
</p>

@ -1,15 +0,0 @@
<h1>Resend confirmation instructions</h1>
<.form let={f} for={:user} action={Routes.user_confirmation_path(@conn, :create)}>
<%= label f, :email %>
<%= email_input f, :email, required: true %>
<div>
<%= submit "Resend confirmation instructions" %>
</div>
</.form>
<p>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
</p>

@ -1,26 +0,0 @@
<h1>Register</h1>
<.form let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create)}>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<%= label f, :email %>
<%= email_input f, :email, required: true %>
<%= error_tag f, :email %>
<%= label f, :password %>
<%= password_input f, :password, required: true %>
<%= error_tag f, :password %>
<div>
<%= submit "Register" %>
</div>
</.form>
<p>
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %> |
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
</p>

@ -1,26 +0,0 @@
<h1>Reset password</h1>
<.form let={f} for={@changeset} action={Routes.user_reset_password_path(@conn, :update, @token)}>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<%= label f, :password, "New password" %>
<%= password_input f, :password, required: true %>
<%= error_tag f, :password %>
<%= label f, :password_confirmation, "Confirm new password" %>
<%= password_input f, :password_confirmation, required: true %>
<%= error_tag f, :password_confirmation %>
<div>
<%= submit "Reset password" %>
</div>
</.form>
<p>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
</p>

@ -1,15 +0,0 @@
<h1>Forgot your password?</h1>
<.form let={f} for={:user} action={Routes.user_reset_password_path(@conn, :create)}>
<%= label f, :email %>
<%= email_input f, :email, required: true %>
<div>
<%= submit "Send instructions to reset password" %>
</div>
</.form>
<p>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
</p>

@ -1,27 +0,0 @@
<h1>Log in</h1>
<.form let={f} for={@conn} action={Routes.user_session_path(@conn, :create)} as={:user}>
<%= if @error_message do %>
<div class="alert alert-danger">
<p><%= @error_message %></p>
</div>
<% end %>
<%= label f, :email %>
<%= email_input f, :email, required: true %>
<%= label f, :password %>
<%= password_input f, :password, required: true %>
<%= label f, :remember_me, "Keep me logged in for 60 days" %>
<%= checkbox f, :remember_me %>
<div>
<%= submit "Log in" %>
</div>
</.form>
<p>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
</p>

@ -1,53 +0,0 @@
<h1>Settings</h1>
<h3>Change email</h3>
<.form let={f} for={@email_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_email">
<%= if @email_changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<%= hidden_input f, :action, name: "action", value: "update_email" %>
<%= label f, :email %>
<%= email_input f, :email, required: true %>
<%= error_tag f, :email %>
<%= label f, :current_password, for: "current_password_for_email" %>
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email" %>
<%= error_tag f, :current_password %>
<div>
<%= submit "Change email" %>
</div>
</.form>
<h3>Change password</h3>
<.form let={f} for={@password_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_password">
<%= if @password_changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<%= hidden_input f, :action, name: "action", value: "update_password" %>
<%= label f, :password, "New password" %>
<%= password_input f, :password, required: true %>
<%= error_tag f, :password %>
<%= label f, :password_confirmation, "Confirm new password" %>
<%= password_input f, :password_confirmation, required: true %>
<%= error_tag f, :password_confirmation %>
<%= label f, :current_password, for: "current_password_for_password" %>
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password" %>
<%= error_tag f, :current_password %>
<div>
<%= submit "Change password" %>
</div>
</.form>

@ -1,9 +1,10 @@
defmodule LinkShortenerWeb.UserAuth do defmodule LinkShortenerWeb.UserAuth do
use LinkShortenerWeb, :verified_routes
import Plug.Conn import Plug.Conn
import Phoenix.Controller import Phoenix.Controller
alias LinkShortener.Accounts alias LinkShortener.Accounts
alias LinkShortenerWeb.Router.Helpers, as: Routes
# Make the remember me cookie valid for 60 days. # Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change # If you want bump or reduce this value, also change
@ -30,8 +31,7 @@ defmodule LinkShortenerWeb.UserAuth do
conn conn
|> renew_session() |> renew_session()
|> put_session(:user_token, token) |> put_token_in_session(token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|> maybe_write_remember_me_cookie(token, params) |> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn)) |> redirect(to: user_return_to || signed_in_path(conn))
end end
@ -60,6 +60,8 @@ defmodule LinkShortenerWeb.UserAuth do
# end # end
# #
defp renew_session(conn) do defp renew_session(conn) do
delete_csrf_token()
conn conn
|> configure_session(renew: true) |> configure_session(renew: true)
|> clear_session() |> clear_session()
@ -72,7 +74,7 @@ defmodule LinkShortenerWeb.UserAuth do
""" """
def log_out_user(conn) do def log_out_user(conn) do
user_token = get_session(conn, :user_token) user_token = get_session(conn, :user_token)
user_token && Accounts.delete_session_token(user_token) user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do if live_socket_id = get_session(conn, :live_socket_id) do
LinkShortenerWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) LinkShortenerWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
@ -81,7 +83,7 @@ defmodule LinkShortenerWeb.UserAuth do
conn conn
|> renew_session() |> renew_session()
|> delete_resp_cookie(@remember_me_cookie) |> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: "/") |> redirect(to: ~p"/")
end end
@doc """ @doc """
@ -95,19 +97,91 @@ defmodule LinkShortenerWeb.UserAuth do
end end
defp ensure_user_token(conn) do defp ensure_user_token(conn) do
if user_token = get_session(conn, :user_token) do if token = get_session(conn, :user_token) do
{user_token, conn} {token, conn}
else else
conn = fetch_cookies(conn, signed: [@remember_me_cookie]) conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if user_token = conn.cookies[@remember_me_cookie] do if token = conn.cookies[@remember_me_cookie] do
{user_token, put_session(conn, :user_token, user_token)} {token, put_token_in_session(conn, token)}
else else
{nil, conn} {nil, conn}
end end
end end
end end
@doc """
Handles mounting and authenticating the current_user in LiveViews.
## `on_mount` arguments
* `:mount_current_user` - Assigns current_user
to socket assigns based on user_token, or nil if
there's no user_token or no matching user.
* `:ensure_authenticated` - Authenticates the user from the session,
and assigns the current_user to socket assigns based
on user_token.
Redirects to login page if there's no logged user.
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
Redirects to signed_in_path if there's a logged user.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the current_user:
defmodule LinkShortenerWeb.PageLive do
use LinkShortenerWeb, :live_view
on_mount {LinkShortenerWeb.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{LinkShortenerWeb.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(socket, session)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
if socket.assigns.current_user do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
{:halt, socket}
end
end
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
if socket.assigns.current_user do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
else
{:cont, socket}
end
end
defp mount_current_user(socket, session) do
Phoenix.Component.assign_new(socket, :current_user, fn ->
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end
end)
end
@doc """ @doc """
Used for routes that require the user to not be authenticated. Used for routes that require the user to not be authenticated.
""" """
@ -134,16 +208,22 @@ defmodule LinkShortenerWeb.UserAuth do
conn conn
|> put_flash(:error, "You must log in to access this page.") |> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to() |> maybe_store_return_to()
|> redirect(to: Routes.user_session_path(conn, :new)) |> redirect(to: ~p"/users/log_in")
|> halt() |> halt()
end end
end end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
end
defp maybe_store_return_to(%{method: "GET"} = conn) do defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn)) put_session(conn, :user_return_to, current_path(conn))
end end
defp maybe_store_return_to(conn), do: conn defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: "/" defp signed_in_path(_conn), do: ~p"/"
end end

@ -1,21 +0,0 @@
defmodule LinkShortenerWeb.Api.V1.LinkView do
use LinkShortenerWeb, :view
alias LinkShortenerWeb.Api.V1.LinkView
def render("index.json", %{links: links}) do
%{data: render_many(links, LinkView, "link.json")}
end
def render("show.json", %{link: link}) do
%{data: render_one(link, LinkView, "link.json")}
end
def render("link.json", %{link: link}) do
%{
id: link.id,
name: link.name,
url: link.url,
shorten: link.shorten
}
end
end

@ -1,12 +0,0 @@
defmodule LinkShortenerWeb.Api.V1.UserView do
use LinkShortenerWeb, :view
alias LinkShortenerWeb.Api.V1.UserView
def render("user.json", %{user: user, token: token}) do
%{
email: user.email,
token: token
}
end
end

@ -1,19 +0,0 @@
defmodule LinkShortenerWeb.ChangesetView do
use LinkShortenerWeb, :view
@doc """
Traverses and translates changeset errors.
See `Ecto.Changeset.traverse_errors/2` and
`LinkShortenerWeb.ErrorHelpers.translate_error/1` for more details.
"""
def translate_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
end
def render("error.json", %{changeset: changeset}) do
# When encoded, the changeset returns its errors
# as a JSON object. So we just pass it forward.
%{errors: translate_errors(changeset)}
end
end

@ -1,47 +0,0 @@
defmodule LinkShortenerWeb.ErrorHelpers do
@moduledoc """
Conveniences for translating and building error messages.
"""
use Phoenix.HTML
@doc """
Generates tag for inlined form input errors.
"""
def error_tag(form, field) do
Enum.map(Keyword.get_values(form.errors, field), fn error ->
content_tag(:span, translate_error(error),
class: "invalid-feedback",
phx_feedback_for: input_name(form, field)
)
end)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate "is invalid" in the "errors" domain
# dgettext("errors", "is invalid")
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# Because the error messages we show in our forms and APIs
# are defined inside Ecto, we need to translate them dynamically.
# This requires us to call the Gettext module passing our gettext
# backend as first argument.
#
# Note we use the "errors" domain, which means translations
# should be written to the errors.po file. The :count option is
# set by Ecto and indicates we should also apply plural rules.
if count = opts[:count] do
Gettext.dngettext(LinkShortenerWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(LinkShortenerWeb.Gettext, "errors", msg, opts)
end
end
end

@ -1,16 +0,0 @@
defmodule LinkShortenerWeb.ErrorView do
use LinkShortenerWeb, :view
# If you want to customize a particular status code
# for a certain format, you may uncomment below.
# def render("500.html", _assigns) do
# "Internal Server Error"
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.html" becomes
# "Not Found".
def template_not_found(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end

@ -1,7 +0,0 @@
defmodule LinkShortenerWeb.LayoutView do
use LinkShortenerWeb, :view
# Phoenix LiveDashboard is available only in development by default,
# so we instruct Elixir to not warn if the dashboard route is missing.
@compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}}
end

@ -1,3 +0,0 @@
defmodule LinkShortenerWeb.PageView do
use LinkShortenerWeb, :view
end

@ -1,3 +0,0 @@
defmodule LinkShortenerWeb.UserConfirmationView do
use LinkShortenerWeb, :view
end

@ -1,3 +0,0 @@
defmodule LinkShortenerWeb.UserRegistrationView do
use LinkShortenerWeb, :view
end

@ -1,3 +0,0 @@
defmodule LinkShortenerWeb.UserResetPasswordView do
use LinkShortenerWeb, :view
end

@ -1,3 +0,0 @@
defmodule LinkShortenerWeb.UserSessionView do
use LinkShortenerWeb, :view
end

@ -1,3 +0,0 @@
defmodule LinkShortenerWeb.UserSettingsView do
use LinkShortenerWeb, :view
end

@ -5,9 +5,8 @@ defmodule LinkShortener.MixProject do
[ [
app: :link_shortener, app: :link_shortener,
version: "0.3.2", version: "0.3.2",
elixir: "~> 1.12", elixir: "~> 1.14",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
compilers: [] ++ Mix.compilers(),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
aliases: aliases(), aliases: aliases(),
deps: deps() deps: deps()
@ -34,25 +33,39 @@ defmodule LinkShortener.MixProject do
defp deps do defp deps do
[ [
{:bcrypt_elixir, "~> 3.0"}, {:bcrypt_elixir, "~> 3.0"},
{:phoenix, "~> 1.6.15"}, {:phoenix, "~> 1.7.14"},
{:phoenix_ecto, "~> 4.4"}, {:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.6"}, {:ecto_sql, "~> 3.10"},
{:postgrex, ">= 0.0.0"}, {:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 3.0"}, {:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 0.17.5"}, # TODO bump on release to {:phoenix_live_view, "~> 1.0.0"},
{:phoenix_live_view, "~> 1.0.0-rc.1", override: true},
{:floki, ">= 0.30.0", only: :test}, {:floki, ">= 0.30.0", only: :test},
{:phoenix_live_dashboard, "~> 0.6"}, {:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.4", runtime: Mix.env() == :dev}, {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
{:swoosh, "~> 1.3"}, {:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
{:telemetry_metrics, "~> 0.6"}, {:heroicons,
github: "tailwindlabs/heroicons",
tag: "v2.1.1",
sparse: "optimized",
app: false,
compile: false,
depth: 1},
{:swoosh, "~> 1.5"},
{:finch, "~> 0.13"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.18"}, {:gettext, "~> 0.20"},
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"}, {:dns_cluster, "~> 0.1.1"},
{:guardian, "~> 1.0"}, {:bandit, "~> 1.5"},
{:comeonin, "~> 5.3"}, {:guardian, "~> 2.3"},
{:guardian_db, "~> 3.0"},
{:poison, "~> 5.0"}, {:poison, "~> 5.0"},
{:ex_machina, "~> 2.8.0", only: :test},
{:wallaby, "~> 0.30", runtime: false, only: :test},
{:flop, "~> 0.26.1"},
] ]
end end
@ -64,11 +77,17 @@ defmodule LinkShortener.MixProject do
# See the documentation for `Mix` for more info on aliases. # See the documentation for `Mix` for more info on aliases.
defp aliases do defp aliases do
[ [
setup: ["deps.get", "ecto.setup"], setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
"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": ["esbuild default --minify", "phx.digest"] "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.build": ["tailwind link_shortener", "esbuild link_shortener"],
"assets.deploy": [
"tailwind link_shortener --minify",
"esbuild link_shortener --minify",
"phx.digest"
]
] ]
end end
end end

@ -1,41 +1,62 @@
%{ %{
"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"}, "bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"},
"castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"},
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"},
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
"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"}, "comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"},
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
"db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
"ecto": {:hex, :ecto, "3.10.2", "6b887160281a61aa16843e47735b8a266caa437f80588c3ab80a8a960e6abe37", [: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", "6a895778f0d7648a4b34b486af59a1c8009041fbdf2b17f1ac215eb829c60235"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
"ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.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", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"}, "ecto": {:hex, :ecto, "3.12.2", "bae2094f038e9664ce5f089e5f3b6132a535d8b018bd280a485c2f33df5c0ce1", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e67c70f3a71c6afe80d946d3ced52ecc57c53c9829791bfff1830ff5a1f0c"},
"elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"}, "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"},
"esbuild": {:hex, :esbuild, "0.7.1", "fa0947e8c3c3c2f86c9bf7e791a0a385007ccd42b86885e8e893bdb6631f5169", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "66661cdf70b1378ee4dc16573fcee67750b59761b2605a0207c267ab9d19f13c"}, "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"},
"expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "ex_machina": {:hex, :ex_machina, "2.8.0", "a0e847b5712065055ec3255840e2c78ef9366634d62390839d4880483be38abe", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "79fe1a9c64c0c1c1fab6c4fa5d871682cb90de5885320c187d117004627a7729"},
"floki": {:hex, :floki, "0.34.3", "5e2dcaec5d7c228ce5b1d3501502e308b2d79eb655e4191751a1fe491c37feac", [:mix], [], "hexpm", "9577440eea5b97924b4bf3c7ea55f7b8b6dce589f9b28b096cc294a8dc342341"}, "expo": {:hex, :expo, "1.0.0", "647639267e088717232f4d4451526e7a9de31a3402af7fcbda09b27e9a10395a", [:mix], [], "hexpm", "18d2093d344d97678e8a331ca0391e85d29816f9664a25653fd7e6166827827c"},
"gettext": {:hex, :gettext, "0.22.3", "c8273e78db4a0bb6fba7e9f0fd881112f349a3117f7f7c598fa18c66c888e524", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "935f23447713954a6866f1bb28c3a878c4c011e802bcd68a726f5e558e4b64bd"}, "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
"guardian": {:hex, :guardian, "1.2.1", "bdc8dd3dbf0fb7216cb6f91c11831faa1a64d39cdaed9a611e37f2413e584983", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "723fc404edfb7bd5cba4cd83329b352037f102aa97468f44e58ac7f47c136a98"}, "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"},
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"},
"jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, "flop": {:hex, :flop, "0.26.1", "f0e9c6895cf876f667e9ff1c0398e53df87087fcd82d9cea8989332b9c0e1358", [:mix], [{:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}], "hexpm", "5fcab8a1ee78111159fc4752dc9823862343b6d6bd527ff947ec1e1c27018485"},
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"},
"phoenix": {:hex, :phoenix, "1.6.16", "e5bdd18c7a06da5852a25c7befb72246de4ddc289182285f8685a40b7b5f5451", [: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", "e15989ff34f670a96b95ef6d1d25bad0d9c50df5df40b671d8f4a669e050ac39"}, "guardian": {:hex, :guardian, "2.3.2", "78003504b987f2b189d76ccf9496ceaa6a454bb2763627702233f31eb7212881", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b189ff38cd46a22a8a824866a6867ca8722942347f13c33f7d23126af8821b52"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.2", "b21bd01fdeffcfe2fab49e4942aa938b6d3e89e93a480d4aee58085560a0bc0d", [:mix], [{:ecto, "~> 3.5", [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", "70242edd4601d50b69273b057ecf7b684644c19ee750989fd555625ae4ce8f5d"}, "guardian_db": {:hex, :guardian_db, "3.0.0", "c42902e3f1af1ba1e2d0c10913b926a1421f3a7e38eb4fc382b715c17489abdb", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "9c2ec4278efa34f9f1cc6ba795e552d41fdc7ffba5319d67eeb533b89392d183"},
"phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~>2.12.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.4.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", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized"]},
"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"}, "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.17.14", "5ec615d4d61bf9d4755f158bd6c80372b715533fe6d6219e12d74fb5eedbeac1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "afeb6ba43ce329a6f7fc1c9acdfc6d3039995345f025febb7f409a92f6faebd3"}, "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
"mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"},
"mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"},
"phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0-rc.6", "47d2669995ea326e5c71f5c1bc9177109cebf211385c638faa7b5862a401e516", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {: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.3 or ~> 4.0 or ~> 4.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]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e56e4f1642a0b20edc2488cab30e5439595e0d8b5b259f76ef98b1c4e2e5b527"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.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"}, "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [: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", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [: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", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"},
"plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"},
"poison": {:hex, :poison, "5.0.0", "d2b54589ab4157bbb82ec2050757779bfed724463a544b6e20d79855a9e43b24", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "11dc6117c501b80c62a7594f941d043982a1bd05a1184280c0d9166eb4d8d3fc"}, "poison": {:hex, :poison, "5.0.0", "d2b54589ab4157bbb82ec2050757779bfed724463a544b6e20d79855a9e43b24", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "11dc6117c501b80c62a7594f941d043982a1bd05a1184280c0d9166eb4d8d3fc"},
"postgrex": {:hex, :postgrex, "0.17.1", "01c29fd1205940ee55f7addb8f1dc25618ca63a8817e56fac4f6846fc2cddcbe", [:mix], [{: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", "14b057b488e73be2beee508fb1955d8db90d6485c6466428fe9ccf1d6692a555"}, "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{: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", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"swoosh": {:hex, :swoosh, "1.11.2", "39dd1e44f75bc03a34366d5f830599d248de2b9caaf05704dc76c0507a58c6a1", [: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", "4c43f4591503e7d5bf028314af8ac7c06d1c4d340aa23faeefabfa2543fa726e"}, "swoosh": {:hex, :swoosh, "1.16.12", "cbb24ad512f2f7f24c7a469661c188a00a8c2cd64e0ab54acd1520f132092dfd", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0e262df1ae510d59eeaaa3db42189a2aa1b3746f73771eb2616fc3f7ee63cc20"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"},
"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": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"},
"telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"},
"tesla": {:hex, :tesla, "1.12.1", "fe2bf4250868ee72e5d8b8dfa408d13a00747c41b7237b6aa3b9a24057346681", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "2391efc6243d37ead43afd0327b520314c7b38232091d4a440c1212626fdd6e7"},
"thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"wallaby": {:hex, :wallaby, "0.30.9", "51d60682092c3c428c63b656b818e2258202b9f9a31ec37230659647ae20325b", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.2.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "62e3ccb89068b231b50ed046219022020516d44f443eebef93a19db4be95b808"},
"web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"},
} }

@ -7,7 +7,6 @@
## Run `mix gettext.extract` to bring this file up to ## Run `mix gettext.extract` to bring this file up to
## date. Leave `msgstr`s empty as changing them here has no ## date. Leave `msgstr`s empty as changing them here has no
## effect: edit them in PO (`.po`) files instead. ## effect: edit them in PO (`.po`) files instead.
## From Ecto.Changeset.cast/4 ## From Ecto.Changeset.cast/4
msgid "can't be blank" msgid "can't be blank"
msgstr "" msgstr ""
@ -48,13 +47,23 @@ msgid "are still associated with this entry"
msgstr "" msgstr ""
## From Ecto.Changeset.validate_length/3 ## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)" msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)" msgid_plural "should be %{count} character(s)"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "should have %{count} item(s)" msgid "should be %{count} byte(s)"
msgid_plural "should have %{count} item(s)" msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
@ -63,8 +72,13 @@ msgid_plural "should be at least %{count} character(s)"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "should have at least %{count} item(s)" msgid "should be at least %{count} byte(s)"
msgid_plural "should have at least %{count} item(s)" msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
@ -73,8 +87,8 @@ msgid_plural "should be at most %{count} character(s)"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "should have at most %{count} item(s)" msgid "should be at most %{count} byte(s)"
msgid_plural "should have at most %{count} item(s)" msgid_plural "should be at most %{count} byte(s)"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""

@ -7,8 +7,9 @@ defmodule LinkShortener.Repo.Migrations.CreateUsersAuthTables do
create table(:users) do create table(:users) do
add :email, :citext, null: false add :email, :citext, null: false
add :hashed_password, :string, null: false add :hashed_password, :string, null: false
add :confirmed_at, :naive_datetime add :confirmed_at, :utc_datetime
timestamps()
timestamps(type: :utc_datetime)
end end
create unique_index(:users, [:email]) create unique_index(:users, [:email])
@ -18,7 +19,8 @@ defmodule LinkShortener.Repo.Migrations.CreateUsersAuthTables do
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(type: :utc_datetime, updated_at: false)
end end
create index(:users_tokens, [:user_id]) create index(:users_tokens, [:user_id])

@ -0,0 +1,17 @@
defmodule LinkShortener.Repo.Migrations.CreateGuardianDBTokensTable do
use Ecto.Migration
def change do
create table(:guardian_tokens, primary_key: false) do
add(:jti, :string, primary_key: true)
add(:aud, :string, primary_key: true)
add(:typ, :string)
add(:iss, :string)
add(:sub, :string)
add(:exp, :bigint)
add(:jwt, :text)
add(:claims, :map)
timestamps()
end
end
end

@ -0,0 +1,11 @@
defmodule LinkShortener.Repo.Migrations.AddRelationshipBetweenUsersAndLinks do
use Ecto.Migration
def change do
alter table(:links) do
add :user_id, references(:users, on_delete: :delete_all), null: false
end
create index(:links, [:user_id])
end
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 152 B

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@ -152,9 +152,9 @@ defmodule LinkShortener.AccountsTest do
test "validates email uniqueness", %{user: user} do test "validates email uniqueness", %{user: user} do
%{email: email} = user_fixture() %{email: email} = user_fixture()
password = valid_user_password()
{:error, changeset} = {:error, changeset} = Accounts.apply_user_email(user, password, %{email: email})
Accounts.apply_user_email(user, valid_user_password(), %{email: email})
assert "has already been taken" in errors_on(changeset).email assert "has already been taken" in errors_on(changeset).email
end end
@ -174,7 +174,7 @@ defmodule LinkShortener.AccountsTest do
end end
end end
describe "deliver_update_email_instructions/3" do describe "deliver_user_update_email_instructions/3" do
setup do setup do
%{user: user_fixture()} %{user: user_fixture()}
end end
@ -182,7 +182,7 @@ defmodule LinkShortener.AccountsTest do
test "sends token through notification", %{user: user} do test "sends token through notification", %{user: user} do
token = token =
extract_user_token(fn url -> extract_user_token(fn url ->
Accounts.deliver_update_email_instructions(user, "current@example.com", url) Accounts.deliver_user_update_email_instructions(user, "current@example.com", url)
end) end)
{:ok, token} = Base.url_decode64(token, padding: false) {:ok, token} = Base.url_decode64(token, padding: false)
@ -200,7 +200,7 @@ defmodule LinkShortener.AccountsTest do
token = token =
extract_user_token(fn url -> extract_user_token(fn url ->
Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url) Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
end) end)
%{user: user, token: token, email: email} %{user: user, token: token, email: email}
@ -353,11 +353,11 @@ defmodule LinkShortener.AccountsTest do
end end
end end
describe "delete_session_token/1" do describe "delete_user_session_token/1" do
test "deletes the token" do test "deletes the token" do
user = user_fixture() user = user_fixture()
token = Accounts.generate_user_session_token(user) token = Accounts.generate_user_session_token(user)
assert Accounts.delete_session_token(token) == :ok assert Accounts.delete_user_session_token(token) == :ok
refute Accounts.get_user_by_session_token(token) refute Accounts.get_user_by_session_token(token)
end end
end end
@ -500,7 +500,7 @@ defmodule LinkShortener.AccountsTest do
end end
end end
describe "inspect/2" do describe "inspect/2 for the User module" do
test "does not include password" do test "does not include password" do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end end

@ -10,9 +10,9 @@ defmodule LinkShortener.Generators.LinkWithRandomShortenTest do
test "generate/1 returns random safe string with 10 as length" do test "generate/1 returns random safe string with 10 as length" do
assert %{ assert %{
name: @name, name: @name,
shorten: shorten shorten: shorten
} = LinkWithRandomShorten.generate_one(@attrs) } = LinkWithRandomShorten.generate_one(@attrs)
assert String.length(shorten) == 10 assert String.length(shorten) == 10
end end
@ -21,9 +21,13 @@ defmodule LinkShortener.Generators.LinkWithRandomShortenTest do
expected_length = 5 expected_length = 5
assert %{ assert %{
name: @name, name: @name,
shorten: shorten shorten: shorten
} = LinkWithRandomShorten.generate_one(@attrs, expected_length) } =
LinkWithRandomShorten.generate_one(@attrs,
is_atom_based: true,
length: expected_length
)
assert String.length(shorten) == expected_length assert String.length(shorten) == expected_length
end end
@ -34,9 +38,15 @@ defmodule LinkShortener.Generators.LinkWithRandomShortenTest do
expected_length = expected_shorten |> String.length() expected_length = expected_shorten |> String.length()
assert %{ assert %{
name: @name, name: @name,
shorten: shorten shorten: shorten
} = LinkWithRandomShorten.generate_one(@attrs, expected_length, random_generator) } =
LinkWithRandomShorten.generate_one(
@attrs,
is_atom_based: true,
length: expected_length,
generator: random_generator
)
assert String.length(shorten) == expected_length assert String.length(shorten) == expected_length
assert shorten == expected_shorten assert shorten == expected_shorten

@ -7,11 +7,10 @@ defmodule LinkShortener.Generators.SafeStringTest do
test "generate/1 returns random safe string with same length" do test "generate/1 returns random safe string with same length" do
for expected_length <- @lengths do for expected_length <- @lengths do
length = expected_length assert ^expected_length =
|> SafeString.generate() expected_length
|> String.length() |> SafeString.generate()
|> String.length()
assert length = expected_length
end end
end end
end end

@ -2,6 +2,11 @@ defmodule LinkShortener.LinksTest do
use LinkShortener.DataCase use LinkShortener.DataCase
alias LinkShortener.Links alias LinkShortener.Links
alias LinkShortener.Links.Link
alias LinkShortener.Factories.UserFactory
import LinkShortener.LinksFixtures
@create_attrs %{ @create_attrs %{
name: "some link name", name: "some link name",
@ -23,31 +28,40 @@ defmodule LinkShortener.LinksTest do
shorten: nil, shorten: nil,
} }
describe "links" do setup do
alias LinkShortener.Links.Link {:ok, user: UserFactory.create_user()}
end
import LinkShortener.LinksFixtures
describe "links" do
test "new_one/1 returns the changeset" do test "new_one/1 returns the changeset" do
assert %Ecto.Changeset{} = Links.new_one() assert %Ecto.Changeset{} = Links.new_one()
end end
test "create_one/1 with valid data creates a link" do test "create_one/1 with valid data creates a link", %{user: user} do
assert {:ok, %Link{} = link} = Links.create_one(@create_generated_attrs) assert {:ok, %Link{} = link} =
with_user(@create_generated_attrs, user)
|> Links.create_one()
assert link.name == "some link name" assert link.name == "some link name"
assert link.url == "https://gitlab.com/KKlochko/link_shortener" assert link.url == "https://gitlab.com/KKlochko/link_shortener"
assert String.length(link.shorten) == 10 assert String.length(link.shorten) == 10
end end
test "create_one/2 with valid data creates a link" do test "create_one/2 with valid data creates a link", %{user: user} do
assert {:ok, %Link{} = link} = Links.create_one(@create_generated_attrs, 5) assert {:ok, %Link{} = link} =
with_user(@create_generated_attrs, user)
|> Links.create_one(length: 5)
assert link.name == "some link name" assert link.name == "some link name"
assert link.url == "https://gitlab.com/KKlochko/link_shortener" assert link.url == "https://gitlab.com/KKlochko/link_shortener"
assert String.length(link.shorten) == 5 assert String.length(link.shorten) == 5
end end
test "insert_one/1 with valid data creates a link" do test "insert_one/1 with valid data creates a link", %{user: user} do
assert {:ok, %Link{} = link} = Links.insert_one(@create_attrs) assert {:ok, %Link{} = link} =
with_user(@create_attrs, user)
|> Links.insert_one()
assert link.name == "some link name" assert link.name == "some link name"
assert link.url == "https://gitlab.com/KKlochko/link_shortener" assert link.url == "https://gitlab.com/KKlochko/link_shortener"
assert link.shorten == "git_repo" assert link.shorten == "git_repo"
@ -103,4 +117,9 @@ defmodule LinkShortener.LinksTest do
assert_raise Ecto.NoResultsError, fn -> Links.get_one!(link.id) end assert_raise Ecto.NoResultsError, fn -> Links.get_one!(link.id) end
end end
end end
defp with_user(attrs, user) do
attrs
|> Map.put(:user_id, user.id)
end
end end

@ -0,0 +1,110 @@
defmodule LinkShortenerWeb.Api.V1.AccountsControllerTest do
use LinkShortenerWeb.ConnCase
import LinkShortener.AccountsFixtures
alias LinkShortener.Accounts.User
@create_attrs %{
email: "user@mail.com",
password: "some password"
}
@update_attrs %{
email: "some updated email",
password: "some updated password"
}
@invalid_password_attrs %{
email: "user@mail.com",
password: ""
}
@invalid_attrs %{
email: nil,
encrypted_password: nil
}
setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end
describe "create user with sign up" do
test "renders user when data is valid", %{conn: conn} do
conn = post(conn, ~p"/api/v1/users/sign-up", user: @create_attrs)
assert %{
"email" => "user@mail.com",
"token" => token
} = json_response(conn, 201)
end
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, ~p"/api/v1/users/sign-up", user: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{}
end
end
describe "user sign in" do
setup [:create_user]
test "renders user when data is valid", %{conn: conn} do
conn = post(conn, ~p"/api/v1/users/sign-in", @create_attrs)
assert %{
"email" => email,
"token" => token,
} = json_response(conn, 201)
end
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, ~p"/api/v1/users/sign-in", @invalid_password_attrs)
assert %{
"errors" => %{"detail" => "Unauthorized"}
} = json_response(conn, 401)
end
end
describe "user signs out" do
setup [:create_user]
setup %{conn: conn} do
%{token: token} = create_user_token()
conn = conn
|> put_req_header("accept", "application/json")
|> put_req_header("authorization", "Bearer #{token}")
{:ok, conn: conn, token: token}
end
test "renders the message and the token if successfully sign out", %{conn: conn, token: token} do
conn = post(conn, ~p"/api/v1/users/sign-out", %{})
assert %{
"message" => "Successfully sign out",
"token" => token,
} = json_response(conn, 200)
end
test "renders errors if the token is invalid after revoke", %{conn: conn, token: token} do
# revoking
conn = post(conn, ~p"/api/v1/users/sign-out", %{})
# second revoking
conn = post(conn, ~p"/api/v1/users/sign-out", %{})
assert %{
"error" => "invalid_token"
} = json_response(conn, 401)
end
end
defp create_user_token() do
token = user_token_fixture()
%{token: token}
end
defp create_user(_) do
user = user_fixture(@create_attrs)
%{user: user}
end
end

@ -12,11 +12,13 @@ defmodule LinkShortenerWeb.Api.V1.LinkControllerTest do
url: "https://gitlab.com/KKlochko/link_shortener", url: "https://gitlab.com/KKlochko/link_shortener",
shorten: "git_repo", shorten: "git_repo",
} }
@update_attrs %{ @update_attrs %{
name: "some updated link name", name: "some updated link name",
url: "https://gitlab.com/KKlochko/link_shortener2", url: "https://gitlab.com/KKlochko/link_shortener2",
shorten: "new_git_repo", shorten: "new_git_repo",
} }
@invalid_attrs %{ @invalid_attrs %{
name: nil, name: nil,
url: nil, url: nil,
@ -35,17 +37,17 @@ defmodule LinkShortenerWeb.Api.V1.LinkControllerTest do
describe "index" do describe "index" do
test "lists all links", %{conn: conn} do test "lists all links", %{conn: conn} do
conn = get(conn, Routes.v1_link_path(conn, :index)) conn = get(conn, ~p"/api/v1/links")
assert json_response(conn, 200)["data"] == [] assert json_response(conn, 200)["data"] == []
end end
end end
describe "create link" do describe "create link" do
test "renders link when data is valid", %{conn: conn} do test "renders link when data is valid", %{conn: conn} do
conn = post(conn, Routes.v1_link_path(conn, :create), link: @create_attrs) conn = post(conn, ~p"/api/v1/links", link: @create_attrs)
assert %{"id" => id} = json_response(conn, 201)["data"] assert %{"id" => id} = json_response(conn, 201)["data"]
conn = get(conn, Routes.v1_link_path(conn, :show, id)) conn = get(conn, ~p"/api/v1/links/#{id}")
assert %{ assert %{
"id" => ^id, "id" => ^id,
@ -56,7 +58,7 @@ defmodule LinkShortenerWeb.Api.V1.LinkControllerTest do
end end
test "renders errors when data is invalid", %{conn: conn} do test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, Routes.v1_link_path(conn, :create), link: @invalid_attrs) conn = post(conn, ~p"/api/v1/links", link: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{} assert json_response(conn, 422)["errors"] != %{}
end end
end end
@ -65,10 +67,10 @@ defmodule LinkShortenerWeb.Api.V1.LinkControllerTest do
setup [:create_link] setup [:create_link]
test "renders link when data is valid", %{conn: conn, link: %Link{id: id} = link} do test "renders link when data is valid", %{conn: conn, link: %Link{id: id} = link} do
conn = put(conn, Routes.v1_link_path(conn, :update, link), link: @update_attrs) conn = put(conn, ~p"/api/v1/links/#{id}", link: @update_attrs)
assert %{"id" => ^id} = json_response(conn, 200)["data"] assert %{"id" => ^id} = json_response(conn, 200)["data"]
conn = get(conn, Routes.v1_link_path(conn, :show, id)) conn = get(conn, ~p"/api/v1/links/#{id}")
assert %{ assert %{
"id" => ^id, "id" => ^id,
@ -78,8 +80,8 @@ defmodule LinkShortenerWeb.Api.V1.LinkControllerTest do
} = json_response(conn, 200)["data"] } = json_response(conn, 200)["data"]
end end
test "renders errors when data is invalid", %{conn: conn, link: link} do test "renders errors when data is invalid", %{conn: conn, link: %Link{id: id} = link} do
conn = put(conn, Routes.v1_link_path(conn, :update, link), link: @invalid_attrs) conn = put(conn, ~p"/api/v1/links/#{id}", link: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{} assert json_response(conn, 422)["errors"] != %{}
end end
end end
@ -87,12 +89,12 @@ defmodule LinkShortenerWeb.Api.V1.LinkControllerTest do
describe "delete link" do describe "delete link" do
setup [:create_link] setup [:create_link]
test "deletes chosen link", %{conn: conn, link: link} do test "deletes chosen link", %{conn: conn, link: %Link{id: id} = link} do
conn = delete(conn, Routes.v1_link_path(conn, :delete, link)) conn = delete(conn, ~p"/api/v1/links/#{id}")
assert response(conn, 204) assert response(conn, 204)
assert_error_sent 404, fn -> assert_error_sent 404, fn ->
get(conn, Routes.v1_link_path(conn, :show, link)) conn = get(conn, ~p"/api/v1/links/#{id}")
end end
end end
end end
@ -107,3 +109,4 @@ defmodule LinkShortenerWeb.Api.V1.LinkControllerTest do
%{link: link} %{link: link}
end end
end end

@ -1,69 +0,0 @@
defmodule LinkShortenerWeb.Api.V1.UserControllerTest do
use LinkShortenerWeb.ConnCase
import LinkShortener.AccountsFixtures
alias LinkShortener.Accounts.User
@create_attrs %{
email: "user@mail.com",
password: "some password"
}
@update_attrs %{
email: "some updated email",
password: "some updated password"
}
@invalid_password_attrs %{
email: "user@mail.com",
password: ""
}
@invalid_attrs %{
email: nil,
encrypted_password: nil
}
setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end
describe "create user with sign up" do
test "renders user when data is valid", %{conn: conn} do
conn = post(conn, Routes.v1_user_path(conn, :create), user: @create_attrs)
assert %{
"email" => "user@mail.com",
"token" => token
} = json_response(conn, 201)
end
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, Routes.v1_user_path(conn, :create), user: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{}
end
end
describe "user sign in" do
setup [:create_user]
test "renders user when data is valid", %{conn: conn} do
conn = post(conn, Routes.v1_user_path(conn, :signin), @create_attrs)
assert %{
"email" => email,
"token" => token,
} = json_response(conn, 201)
end
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, Routes.v1_user_path(conn, :signin), @invalid_password_attrs)
assert "Unauthorized" == json_response(conn, 401)
end
end
defp create_user(_) do
user = user_fixture(@create_attrs)
%{user: user}
end
end

@ -0,0 +1,14 @@
defmodule LinkShortenerWeb.ErrorHTMLTest do
use LinkShortenerWeb.ConnCase, async: true
# Bring render_to_string/4 for testing custom views
import Phoenix.Template
test "renders 404.html" do
assert render_to_string(LinkShortenerWeb.ErrorHTML, "404", "html", []) == "Not Found"
end
test "renders 500.html" do
assert render_to_string(LinkShortenerWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
end
end

@ -0,0 +1,12 @@
defmodule LinkShortenerWeb.ErrorJSONTest do
use LinkShortenerWeb.ConnCase, async: true
test "renders 404" do
assert LinkShortenerWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
end
test "renders 500" do
assert LinkShortenerWeb.ErrorJSON.render("500.json", %{}) ==
%{errors: %{detail: "Internal Server Error"}}
end
end

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

Loading…
Cancel
Save