mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
Replace web app with a new one based on Tailwind and esbuild (#1568)
This commit is contained in:
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -351,14 +351,14 @@ jobs:
|
||||
run: mix deps.compile --skip-umbrella-children
|
||||
- name: Compile Application
|
||||
run: mix compile
|
||||
- name: Install Node Dependencies
|
||||
- name: Install Front-End Dependencies
|
||||
run: |
|
||||
cd apps/web/assets
|
||||
yarn install --frozen-lockfile
|
||||
cd apps/web
|
||||
mix assets.setup
|
||||
- name: Build Assets
|
||||
run: |
|
||||
cd apps/web/assets
|
||||
yarn deploy
|
||||
cd apps/web
|
||||
mix assets.build
|
||||
- name: Setup Database
|
||||
run: |
|
||||
mix ecto.create
|
||||
|
||||
@@ -47,7 +47,6 @@ RUN mix do deps.get, deps.compile, compile
|
||||
# Copy more granular, dependency management files first to prevent
|
||||
# busting the Docker build cache unnecessarily
|
||||
COPY apps/web/assets/package.json /var/app/apps/web/assets/package.json
|
||||
COPY apps/web/assets/local_modules /var/app/apps/web/assets/local_modules
|
||||
COPY apps/web/assets/yarn.lock /var/app/apps/web/assets/yarn.lock
|
||||
RUN cd apps/web/assets && yarn install
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ defmodule Domain.MixProject do
|
||||
lockfile: "../../mix.lock",
|
||||
elixir: "~> 1.12",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
compilers: Mix.compilers(),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
test_coverage: [tool: ExCoveralls],
|
||||
preferred_cli_env: [
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
[
|
||||
import_deps: [:phoenix, :phoenix_live_view],
|
||||
plugins: [Phoenix.LiveView.HTMLFormatter],
|
||||
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"],
|
||||
locals_without_parens: [
|
||||
assert_authenticated: 2,
|
||||
assert_unauthenticated: 1
|
||||
],
|
||||
import_deps: [
|
||||
:phoenix,
|
||||
:phoenix_live_view
|
||||
],
|
||||
inputs: [
|
||||
"*.{heex,ex,exs}",
|
||||
"{lib,test,priv}/**/*.{heex,ex,exs}"
|
||||
],
|
||||
plugins: [
|
||||
Phoenix.LiveView.HTMLFormatter
|
||||
]
|
||||
]
|
||||
|
||||
24
apps/web/.gitignore
vendored
24
apps/web/.gitignore
vendored
@@ -1,6 +1,3 @@
|
||||
# macOS cruft
|
||||
.DS_Store
|
||||
|
||||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
@@ -22,18 +19,19 @@ erl_crash.dump
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
cloudfire-*.tar
|
||||
web-*.tar
|
||||
|
||||
# If NPM crashes, it generates a log, let's ignore it too.
|
||||
# Ignore assets that are produced by build tools.
|
||||
/priv/static/assets/
|
||||
|
||||
# Ignore digested assets cache.
|
||||
/priv/static/cache_manifest.json
|
||||
|
||||
# In case you use Node.js/npm, you want to ignore these.
|
||||
npm-debug.log
|
||||
|
||||
# The directory NPM downloads your dependencies sources to.
|
||||
/assets/node_modules/
|
||||
/assets/lib/node_modules/
|
||||
/assets/bin/
|
||||
|
||||
# Since we are building assets from assets/,
|
||||
# we ignore priv/static. You may want to comment
|
||||
# this depending on your deployment strategy.
|
||||
/priv/static/dist/
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
# Domain
|
||||
# Web
|
||||
|
||||
Phoenix app for managing Firezone.
|
||||
To start your Phoenix server:
|
||||
|
||||
* Run `mix setup` to install and setup dependencies
|
||||
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
|
||||
|
||||
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
||||
|
||||
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
||||
|
||||
## Learn more
|
||||
|
||||
* Official website: https://www.phoenixframework.org/
|
||||
* Guides: https://hexdocs.pm/phoenix/overview.html
|
||||
* Docs: https://hexdocs.pm/phoenix
|
||||
* Forum: https://elixirforum.com/c/phoenix-forum
|
||||
* Source: https://github.com/phoenixframework/phoenix
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-env"
|
||||
]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
const { config: prodConfig } = require('./config.prod')
|
||||
|
||||
module.exports.config = {
|
||||
...prodConfig,
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
watch: true,
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
const path = require('path')
|
||||
const { sassPlugin } = require('esbuild-sass-plugin')
|
||||
|
||||
const tildePlugin = {
|
||||
name: 'tilde',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^~/ }, args => ({
|
||||
path: path.join(
|
||||
args.resolveDir,
|
||||
'..',
|
||||
args.path
|
||||
.replace(/^~/, 'node_modules/')
|
||||
.replace(/\?.*$|#\w+$/, '')
|
||||
),
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
module.exports.config = {
|
||||
entryPoints: ['js/admin.js', 'js/root.js', 'js/unprivileged.js'],
|
||||
bundle: true,
|
||||
outdir: '../priv/static/dist',
|
||||
publicPath: '/dist/',
|
||||
minify: true,
|
||||
plugins: [
|
||||
tildePlugin,
|
||||
sassPlugin({
|
||||
loadPaths: ['.'],
|
||||
})],
|
||||
loader: {
|
||||
'.eot': 'file',
|
||||
'.svg': 'file',
|
||||
'.ttf': 'file',
|
||||
'.woff': 'file',
|
||||
'.woff2': 'file',
|
||||
}
|
||||
}
|
||||
5
apps/web/assets/css/app.css
Normal file
5
apps/web/assets/css/app.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "tailwindcss/base";
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
@@ -1,21 +0,0 @@
|
||||
/* This file is for your main application css. */
|
||||
|
||||
@charset "utf-8";
|
||||
@import "~admin-one-bulma-dashboard/src/scss/main.scss";
|
||||
@import "./main.scss";
|
||||
@import "./email.scss";
|
||||
@import "./tables.scss";
|
||||
|
||||
/* Font Awesome */
|
||||
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
|
||||
@import "~@fortawesome/fontawesome-free/scss/fontawesome.scss";
|
||||
@import "~@fortawesome/fontawesome-free/scss/solid.scss";
|
||||
@import "~@fortawesome/fontawesome-free/scss/regular.scss";
|
||||
@import "~@fortawesome/fontawesome-free/scss/brands.scss";
|
||||
|
||||
/* Material Design Icons */
|
||||
$mdi-font-path: "~@mdi/font/fonts";
|
||||
@import "~@mdi/font/scss/materialdesignicons.scss";
|
||||
|
||||
/* Bulma Tooltip */
|
||||
@import "~@creativebulma/bulma-tooltip/src/sass/index.sass";
|
||||
@@ -1 +0,0 @@
|
||||
/* Email Styles */
|
||||
@@ -1,66 +0,0 @@
|
||||
/* Main Styles */
|
||||
|
||||
nav.navbar {
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.code {
|
||||
@extend .is-family-monospace;
|
||||
}
|
||||
|
||||
pre.multiline {
|
||||
white-space: pre-wrap; /* Since CSS 2.1 */
|
||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||
white-space: -pre-wrap; /* Opera 4-6 */
|
||||
white-space: -o-pre-wrap; /* Opera 7 */
|
||||
word-wrap: break-word; /* Internet Explorer 5.5+ */
|
||||
}
|
||||
|
||||
.is-horizontally-scrollable {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.is-vertically-scrollable {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: $interface-000;
|
||||
color: $interface-600;
|
||||
}
|
||||
|
||||
.dropdown-menu.is-large {
|
||||
width: 26rem;
|
||||
}
|
||||
|
||||
.pre-wrapped {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.is-main-section {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.line-clamp {
|
||||
// supported in all browsers but with prefix
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
max-width: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.switch {
|
||||
&:hover input[type=checkbox]:disabled+.check {
|
||||
background: rgba(181, 181, 181, 1);
|
||||
}
|
||||
|
||||
&:hover input[type=checkbox]:checked:disabled+.check {
|
||||
background: rgba(94, 0, 214, 1);
|
||||
}
|
||||
|
||||
input[type=checkbox]:disabled+.check {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
$widths: (1, 2, 3, 4, 5, 6, 7);
|
||||
|
||||
@each $width in $widths {
|
||||
$i: index($widths, $width);
|
||||
.table thead th.is-#{$i} {
|
||||
width: #{$width}em !important;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
const esbuild = require('esbuild')
|
||||
const { config } = require(`./config.${process.argv[2]}`)
|
||||
|
||||
esbuild.build(config).catch(() => process.exit(1))
|
||||
@@ -1,11 +0,0 @@
|
||||
import css from "../css/app.scss"
|
||||
import "admin-one-bulma-dashboard/src/js/main.js"
|
||||
|
||||
/* Application fonts */
|
||||
import "@fontsource/fira-sans"
|
||||
import "@fontsource/open-sans"
|
||||
import "@fontsource/fira-mono"
|
||||
|
||||
import "phoenix_html"
|
||||
import "./live_view.js"
|
||||
import "./event_listeners.js"
|
||||
41
apps/web/assets/js/app.js
Normal file
41
apps/web/assets/js/app.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
|
||||
// to get started and then uncomment the line below.
|
||||
// import "./user_socket.js"
|
||||
|
||||
// You can include dependencies in two ways.
|
||||
//
|
||||
// The simplest option is to put them in assets/vendor and
|
||||
// import them using relative paths:
|
||||
//
|
||||
// import "../vendor/some-package.js"
|
||||
//
|
||||
// Alternatively, you can `npm install some-package --prefix assets` and import
|
||||
// them using a path starting with the package name:
|
||||
//
|
||||
// import "some-package"
|
||||
//
|
||||
|
||||
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
||||
import "phoenix_html"
|
||||
// Establish Phoenix Socket and LiveView configuration.
|
||||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import topbar from "../vendor/topbar"
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
liveSocket.connect()
|
||||
|
||||
// expose liveSocket on window for web console debug logs and latency simulation:
|
||||
// >> liveSocket.enableDebug()
|
||||
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { box } from "tweetnacl/nacl-fast"
|
||||
import { encodeBase64 } from "tweetnacl-util"
|
||||
|
||||
let fzCrypto = {
|
||||
generateKeyPair () {
|
||||
let kp = box.keyPair()
|
||||
return {
|
||||
privateKey: encodeBase64(kp.secretKey),
|
||||
publicKey: encodeBase64(kp.publicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { fzCrypto }
|
||||
@@ -1,38 +0,0 @@
|
||||
import {FormatTimestamp} from "./util.js"
|
||||
|
||||
// Notification dismiss
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
(document.querySelectorAll('.notification .delete') || []).forEach(($delete) => {
|
||||
const $notification = $delete.parentNode
|
||||
|
||||
$delete.addEventListener('click', () => {
|
||||
$notification.parentNode.removeChild($notification)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
(document.querySelectorAll('[data-timestamp]') || []).forEach(($span) => {
|
||||
$span.innerHTML = FormatTimestamp($span.dataset.timestamp)
|
||||
})
|
||||
})
|
||||
|
||||
document.addEventListener("firezone:clipcopy", (event) => {
|
||||
if ("clipboard" in navigator) {
|
||||
const text = event.target.textContent
|
||||
navigator.clipboard.writeText(text)
|
||||
const dispatcher = event.detail.dispatcher
|
||||
const span = dispatcher.getElementsByTagName("span")[0]
|
||||
const icon = dispatcher.getElementsByTagName("i")[0]
|
||||
|
||||
span.classList.add("has-text-success")
|
||||
icon.classList.replace("mdi-content-copy", "mdi-check-bold")
|
||||
|
||||
setTimeout(() => {
|
||||
span.classList.remove("has-text-success")
|
||||
icon.classList.replace("mdi-check-bold", "mdi-content-copy")
|
||||
}, 1000)
|
||||
} else {
|
||||
alert("Sorry, your browser does not support clipboard copy.")
|
||||
}
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import hljs from "highlight.js"
|
||||
import { FormatTimestamp, PasswordStrength } from "./util.js"
|
||||
import { renderConfig } from "./wg_conf.js"
|
||||
import { renderQR } from "./qrcode.js"
|
||||
import { fzCrypto } from "./crypto.js"
|
||||
|
||||
const highlightCode = function () {
|
||||
hljs.highlightAll()
|
||||
}
|
||||
|
||||
const formatTimestamp = function () {
|
||||
let t = this.el.dataset.timestamp
|
||||
this.el.innerHTML = FormatTimestamp(t)
|
||||
}
|
||||
|
||||
const passwordStrength = function () {
|
||||
const field = this.el
|
||||
const fieldClasses = "password input "
|
||||
const progress = document.getElementById(field.dataset.target)
|
||||
const reset = function () {
|
||||
field.className = fieldClasses
|
||||
progress.className = "is-hidden"
|
||||
progress.setAttribute("value", "0")
|
||||
progress.innerHTML = "0%"
|
||||
}
|
||||
field.addEventListener("input", () => {
|
||||
if (field.value === "") return reset()
|
||||
const score = PasswordStrength(field.value)
|
||||
switch (score) {
|
||||
case 0:
|
||||
case 1:
|
||||
field.className = fieldClasses + "is-danger"
|
||||
progress.className = "progress is-small is-danger"
|
||||
progress.setAttribute("value", "33")
|
||||
progress.innerHTML = "33%"
|
||||
break
|
||||
case 2:
|
||||
case 3:
|
||||
field.className = fieldClasses + "is-warning"
|
||||
progress.className = "progress is-small is-warning"
|
||||
progress.setAttribute("value", "67")
|
||||
progress.innerHTML = "67%"
|
||||
break
|
||||
case 4:
|
||||
field.className = fieldClasses + "is-success"
|
||||
progress.className = "progress is-small is-success"
|
||||
progress.setAttribute("value", "100")
|
||||
progress.innerHTML = "100%"
|
||||
break
|
||||
default:
|
||||
reset()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const generateKeyPair = function () {
|
||||
let kp = fzCrypto.generateKeyPair()
|
||||
this.el.value = kp.publicKey
|
||||
|
||||
// XXX: Verify
|
||||
sessionStorage.setItem(kp.publicKey, kp.privateKey)
|
||||
}
|
||||
|
||||
const clipboardCopy = function () {
|
||||
let button = this.el
|
||||
let data = button.dataset.clipboard
|
||||
button.addEventListener("click", () => {
|
||||
button.dataset.tooltip = "Copied!"
|
||||
navigator.clipboard.writeText(data)
|
||||
})
|
||||
}
|
||||
|
||||
let Hooks = {}
|
||||
Hooks.ClipboardCopy = {
|
||||
mounted: clipboardCopy,
|
||||
updated: clipboardCopy
|
||||
}
|
||||
Hooks.HighlightCode = {
|
||||
mounted: highlightCode,
|
||||
updated: highlightCode
|
||||
}
|
||||
Hooks.FormatTimestamp = {
|
||||
mounted: formatTimestamp,
|
||||
updated: formatTimestamp
|
||||
}
|
||||
Hooks.PasswordStrength = {
|
||||
mounted: passwordStrength,
|
||||
updated: passwordStrength
|
||||
}
|
||||
Hooks.RenderConfig = {
|
||||
mounted: renderConfig,
|
||||
updated: renderConfig
|
||||
}
|
||||
Hooks.RenderQR = {
|
||||
mounted: renderQR,
|
||||
updated: renderQR
|
||||
}
|
||||
Hooks.GenerateKeyPair = {
|
||||
mounted: generateKeyPair
|
||||
}
|
||||
|
||||
export default Hooks
|
||||
@@ -1,73 +0,0 @@
|
||||
// Encapsulates LiveView initialization
|
||||
import Hooks from "./hooks.js"
|
||||
import {Socket, Presence} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import {FormatTimestamp} from "./util.js"
|
||||
|
||||
// User Socket
|
||||
const userToken = document
|
||||
.querySelector("meta[name='user-token']")
|
||||
.getAttribute("content")
|
||||
const userSocket = new Socket("/socket", {
|
||||
params: {
|
||||
token: userToken
|
||||
}
|
||||
})
|
||||
|
||||
// Notifications
|
||||
const channelToken = document
|
||||
.querySelector("meta[name='channel-token']")
|
||||
.getAttribute("content")
|
||||
const notificationChannel =
|
||||
userSocket.channel("notification:session", {
|
||||
token: channelToken
|
||||
})
|
||||
|
||||
// LiveView setup
|
||||
const csrfToken = document
|
||||
.querySelector("meta[name='csrf-token']")
|
||||
.getAttribute("content")
|
||||
const liveSocket = new LiveSocket(
|
||||
"/live",
|
||||
Socket,
|
||||
{
|
||||
hooks: Hooks,
|
||||
params: {
|
||||
_csrf_token: csrfToken
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const toggleConnectStatus = function (info) {
|
||||
let success = document.getElementById("web-ui-connect-success")
|
||||
let error = document.getElementById("web-ui-connect-error")
|
||||
if (success && error) {
|
||||
if (userSocket.isConnected()) {
|
||||
success.classList.remove("is-hidden")
|
||||
error.classList.add("is-hidden")
|
||||
} else {
|
||||
success.classList.add("is-hidden")
|
||||
error.classList.remove("is-hidden")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userSocket.onError(toggleConnectStatus)
|
||||
userSocket.onOpen(toggleConnectStatus)
|
||||
userSocket.onClose(toggleConnectStatus)
|
||||
|
||||
// uncomment to connect if there are any LiveViews on the page
|
||||
liveSocket.connect()
|
||||
userSocket.connect()
|
||||
|
||||
notificationChannel.join()
|
||||
// .receive("ok", ({messages}) => console.log("catching up", messages))
|
||||
// .receive("error", ({reason}) => console.log("error", reason))
|
||||
// .receive("timeout", () => console.log("Networking issue. Still waiting..."))
|
||||
|
||||
// expose liveSocket on window for web console debug logs and latency simulation:
|
||||
// >> liveSocket.enableDebug()
|
||||
// >> liveSocket.enableLatencySim(1000)
|
||||
|
||||
window.liveSocket = liveSocket
|
||||
window.userSocket = userSocket
|
||||
@@ -1,23 +0,0 @@
|
||||
const QRCode = require('qrcode')
|
||||
|
||||
const qrError = function (el, error) {
|
||||
console.error(error)
|
||||
el.parentNode.removeChild(el)
|
||||
}
|
||||
|
||||
const renderQR = function (data, canvas, width) {
|
||||
canvas ||= this.el
|
||||
data ||= canvas.dataset.qrdata
|
||||
width ||= canvas.dataset.size || 375
|
||||
if (canvas) {
|
||||
QRCode.toCanvas(canvas, data, {
|
||||
errorCorrectionLevel: "L",
|
||||
margin: 0,
|
||||
width: +width
|
||||
}, function (error) {
|
||||
if (error) qrError(canvas, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { renderQR }
|
||||
@@ -1,30 +0,0 @@
|
||||
import css from "../css/app.scss"
|
||||
|
||||
/* Application fonts */
|
||||
import "@fontsource/fira-sans"
|
||||
import "@fontsource/open-sans"
|
||||
import "@fontsource/fira-mono"
|
||||
|
||||
import "phoenix_html"
|
||||
import Hooks from "./hooks.js"
|
||||
import { Socket } from "phoenix"
|
||||
import { LiveSocket } from "phoenix_live_view"
|
||||
import "./event_listeners.js"
|
||||
|
||||
// Basic LiveView setup
|
||||
const csrfToken = document
|
||||
.querySelector("meta[name='csrf-token']")
|
||||
.getAttribute("content")
|
||||
const liveSocket = new LiveSocket(
|
||||
"/live",
|
||||
Socket,
|
||||
{
|
||||
hooks: Hooks,
|
||||
params: {
|
||||
_csrf_token: csrfToken
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
liveSocket.connect()
|
||||
window.liveSocket = liveSocket
|
||||
@@ -1,10 +0,0 @@
|
||||
import css from "../css/app.scss"
|
||||
|
||||
/* Application fonts */
|
||||
import "@fontsource/fira-sans"
|
||||
import "@fontsource/open-sans"
|
||||
import "@fontsource/fira-mono"
|
||||
|
||||
import "phoenix_html"
|
||||
import "./live_view.js"
|
||||
import "./event_listeners.js"
|
||||
@@ -1,28 +0,0 @@
|
||||
import zxcvbn from "zxcvbn"
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(
|
||||
'en-US',
|
||||
{
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
}
|
||||
)
|
||||
|
||||
const FormatTimestamp = function (timestamp) {
|
||||
if (timestamp) {
|
||||
return dateFormatter.format(new Date(timestamp))
|
||||
} else {
|
||||
return "Never"
|
||||
}
|
||||
}
|
||||
|
||||
const PasswordStrength = function (password) {
|
||||
const result = zxcvbn(password)
|
||||
return result.score
|
||||
}
|
||||
|
||||
export { PasswordStrength, FormatTimestamp }
|
||||
@@ -1,61 +0,0 @@
|
||||
import { renderQR } from './qrcode.js'
|
||||
|
||||
const alertPrivateKeyError = function () {
|
||||
}
|
||||
|
||||
// 1. Load generated keypair from previous step
|
||||
// 2. Replace config PrivateKey sentinel with PrivateKey
|
||||
// 3. Set code el innerHTML to new config
|
||||
// 4. render QR code
|
||||
// 5. render download button
|
||||
const renderConfig = function () {
|
||||
const publicKey = this.el.dataset.publicKey
|
||||
const deviceName = this.el.dataset.deviceName
|
||||
if (publicKey) {
|
||||
const privateKey = sessionStorage.getItem(publicKey)
|
||||
|
||||
// XXX: Clear all private keys
|
||||
setTimeout(() => {
|
||||
sessionStorage.removeItem(publicKey)
|
||||
}, 5000);
|
||||
|
||||
const placeholder = document.getElementById("generating-config")
|
||||
|
||||
if (privateKey) {
|
||||
const templateConfig = atob(this.el.dataset.config)
|
||||
const config = templateConfig.replace("REPLACE_ME", privateKey)
|
||||
|
||||
renderDownloadButton(config, deviceName)
|
||||
renderQR(config, document.getElementById("qr-canvas"))
|
||||
renderTunnel(config)
|
||||
|
||||
placeholder.classList.add("is-hidden")
|
||||
} else {
|
||||
placeholder.innerHTML =
|
||||
`<p>
|
||||
Error generating configuration. Could not load private key from
|
||||
sessionStorage. Close window and try again. If the issue persists,
|
||||
please contact support@firezone.dev.
|
||||
</p>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderDownloadButton = function (config, deviceName) {
|
||||
let button = document.getElementById("download-config")
|
||||
button.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(config))
|
||||
button.setAttribute("download", deviceName + ".conf")
|
||||
button.addEventListener('click', (event) => {
|
||||
event.stopPropagation()
|
||||
})
|
||||
button.classList.remove("is-hidden")
|
||||
}
|
||||
|
||||
const renderTunnel = function (config) {
|
||||
let code = document.getElementById("wg-conf")
|
||||
let container = document.getElementById("wg-conf-container")
|
||||
code.innerHTML = config
|
||||
container.classList.remove("is-hidden")
|
||||
}
|
||||
|
||||
export { renderConfig }
|
||||
@@ -1,15 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{html,js,yml,yaml,js,scss,css,json}]
|
||||
indent_size = 2
|
||||
@@ -1,2 +0,0 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -1,10 +0,0 @@
|
||||
# Files
|
||||
.DS_Store
|
||||
npm-debug.log
|
||||
|
||||
# Folders
|
||||
.idea/
|
||||
node_modules
|
||||
|
||||
# Hot
|
||||
hot
|
||||
@@ -1 +0,0 @@
|
||||
nodejs 16.6.2
|
||||
@@ -1,21 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2019-2020 JustBoil.me (https://justboil.me)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,131 +0,0 @@
|
||||
# [Admin One HTML — Free Bulma Admin Dashboard](https://justboil.me/bulma-admin-template/one-html)
|
||||
|
||||
[](https://justboil.me/bulma-admin-template/one-html) [](https://justboil.me/bulma-admin-template/one-html)
|
||||
|
||||
[](https://vikdiesel.github.io/admin-one-bulma-dashboard/)
|
||||
|
||||
**Admin One HTML** is simple, beautiful and free Bulma admin dashboard (pure HTML version).
|
||||
|
||||
* Free under MIT License
|
||||
* Built with Bulma CSS Framework
|
||||
* Pure HTML & CSS/SCSS
|
||||
* No js framework dependencies
|
||||
* Ready-to-use CSS files
|
||||
* SCSS sources with variables
|
||||
* [Premium version](https://justboil.me/bulma-admin-template/one-html) available
|
||||
|
||||
## Table of Contents
|
||||
|
||||
* [Other versions](#other-versions)
|
||||
* [Description & Demo](#description--demo)
|
||||
* [Quick Start](#quick-start)
|
||||
* [Browser Support](#browser-support)
|
||||
* [Reporting Issues](#reporting-issues)
|
||||
* [Licensing](#licensing)
|
||||
* [Useful Links](#useful-links)
|
||||
|
||||
## Other versions
|
||||
|
||||
This is Bulma HTML/CSS/SCSS dashboard version.
|
||||
|
||||
### Tailwind CSS
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><a href="https://justboil.me/tailwind-admin-templates"><img src="https://justboil.me/images/tailwind-gh-logo.png?v=2" width="219" height="40" alt="Tailwind CSS admin dashboard templates"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/justboil/admin-one-tailwind" title="Free Tailwind CSS admin dashboard HTML"><img src="https://justboil.me/svg/language-html5.svg" width="64" height="64"></a></td>
|
||||
<td align="center"><a href="https://github.com/justboil/admin-one-vue-tailwind" title="Free Vue.js 3 Tailwind CSS admin dashboard"><img src="https://justboil.me/svg/vuejs.svg" width="64" height="64"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Tailwind admin dashboard<br/>Pure HTML/CSS<br/><br/><a href="https://github.com/justboil/admin-one-tailwind" title="Free Tailwind admin dashboard HTML CSS">Free</a></td>
|
||||
<td align="center">Tailwind admin dashboard<br/>Vue.js 3<br/><br/><a href="https://github.com/justboil/admin-one-vue-tailwind" title="Free Vue.js 3 Tailwind CSS admin dashboard">Free</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Bulma
|
||||
|
||||
More info on free & premium versions of Admin One Dashboard: https://justboil.me/bulma-admin-template/one
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" colspan="4"><a href="https://justboil.me/"><img src="https://justboil.me/images/bulma-gh-logo.png" width="219" height="40" alt="Bulma admin dashboard templates"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/vikdiesel/admin-one-bulma-dashboard" title="Free Bulma admin dashboard HTML CSS SCSS"><img src="https://justboil.me/svg/language-html5.svg" width="64" height="64"></a></td>
|
||||
<td align="center"><a href="https://github.com/vikdiesel/admin-one-vue-bulma-dashboard" title="Free Bulma Vue.js Buefy admin dashboard"><img src="https://justboil.me/svg/vuejs.svg" width="64" height="64"></a></td>
|
||||
<td align="center"><a href="https://github.com/justboil/admin-one-nuxt" title="Free Bulma Nuxt.js Buefy admin dashboard"><img src="https://justboil.me/svg/nuxt.svg" width="64" height="64"></a></td>
|
||||
<td align="center"><a href="https://github.com/vikdiesel/admin-one-laravel-dashboard" title="Free Bulma Laravel admin dashboard"><img src="https://justboil.me/svg/laravel.svg" width="64" height="64"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Bulma admin dashboard<br/>HTML/CSS/SCSS<br/><br/><a href="https://github.com/vikdiesel/admin-one-bulma-dashboard" title="Free Bulma admin dashboard HTML CSS SCSS">Free</a> | <a href="https://justboil.me/bulma-admin-template/one-html" title="Premium Bulma admin dashboard HTML CSS SCSS">Premium</a></td>
|
||||
<td align="center">Bulma admin dashboard<br/>Vue.js Buefy<br/><br/><a href="https://github.com/vikdiesel/admin-one-vue-bulma-dashboard" title="Free Bulma Vue.js Buefy admin dashboard">Free</a> | <a href="https://justboil.me/bulma-admin-template/one" title="Premium Bulma Vue.js Buefy admin dashboard">Premium</a></td>
|
||||
<td align="center">Bulma admin dashboard<br/>Nuxt.js Buefy<br/><br/><a href="https://github.com/justboil/admin-one-nuxt" title="Free Bulma Nuxt.js Buefy admin dashboard">Free</a> | <a href="https://justboil.me/bulma-admin-template/one-nuxt" title="Premium Bulma Nuxt.js Buefy admin dashboard">Premium</a></td>
|
||||
<td align="center">Bulma admin dashboard<br/>Laravel<br/><br/><a href="https://github.com/vikdiesel/admin-one-laravel-dashboard" title="Free Bulma Laravel admin dashboard">Free</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Description & Demo
|
||||
|
||||
#### Description
|
||||
|
||||
https://justboil.me/bulma-admin-template/one-html
|
||||
|
||||
#### Free Dashboard Demo
|
||||
|
||||
https://vikdiesel.github.io/admin-one-bulma-dashboard/
|
||||
|
||||
#### Premium Dashboard Demo
|
||||
|
||||
https://admin-one-html.justboil.me
|
||||
|
||||
## Quick Start
|
||||
|
||||
#### Get the repo
|
||||
|
||||
* [Create new repo](https://github.com/vikdiesel/admin-one-bulma-dashboard/generate) from this template
|
||||
* Clone the repo on GitHub
|
||||
* … or [download .zip](https://github.com/vikdiesel/admin-vue-bulma-dashboard/archive/master.zip) from GitHub
|
||||
|
||||
#### HTML & CSS
|
||||
|
||||
Check `demo` directory.
|
||||
|
||||
#### npm tools
|
||||
|
||||
##### Install
|
||||
|
||||
`cd` to project's dir and run `npm install`
|
||||
|
||||
##### Build
|
||||
|
||||
`npm run build` to rebuild `demo` from sources in `src` directory
|
||||
|
||||
## Browser Support
|
||||
|
||||
We try to make sure Dashboard works well in the latest versions of all major browsers
|
||||
|
||||
<img src="https://justboil.me/images/browsers-svg/chrome.svg" width="64" height="64" alt="Chrome"> <img src="https://justboil.me/images/browsers-svg/firefox.svg" width="64" height="64" alt="Firefox"> <img src="https://justboil.me/images/browsers-svg/edge.svg" width="64" height="64" alt="Edge"> <img src="https://justboil.me/images/browsers-svg/safari.svg" width="64" height="64" alt="Safari"> <img src="https://justboil.me/images/browsers-svg/opera.svg" width="64" height="64" alt="Opera">
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
JustBoil's free items are limited to community support on GitHub.
|
||||
|
||||
The issue list is reserved exclusively for bug reports and feature requests. That means we do not accept usage questions. If you open an issue that does not conform to the requirements, it will be closed.
|
||||
|
||||
1. Make sure that you are using the latest version of the Dashboard. Issues for outdated versions are irrelevant
|
||||
2. Provide steps to reproduce
|
||||
3. Provide an expected behavior
|
||||
4. Describe what is actually happening
|
||||
5. Platform, Browser & version as some issues may be browser specific
|
||||
|
||||
## Licensing
|
||||
|
||||
- Copyright © 2019-2020 JustBoil.me (https://justboil.me)
|
||||
- Licensed under MIT
|
||||
|
||||
## Useful Links
|
||||
|
||||
- [JustBoil.me](https://justboil.me)
|
||||
- [Bulma](https://bulma.io)
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "admin-one-bulma-dashboard",
|
||||
"version": "1.5.5",
|
||||
"description": "Admin One Bulma Admin Dashboard (HTML version)",
|
||||
"homepage": "https://justboil.me/bulma-admin-template/one-html",
|
||||
"keywords": [
|
||||
"sass",
|
||||
"bulma",
|
||||
"css",
|
||||
"html",
|
||||
"dashboard"
|
||||
],
|
||||
"author": "Viktor Kuzhelnyi <viktor@justboil.me> (https://justboil.me)",
|
||||
"contributors": [
|
||||
"Jamil Bou Kheir <jamil@firez.one>"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bulma": "^0.9.0",
|
||||
"bulma-checkbox": "^1.1.1",
|
||||
"bulma-radio": "^1.1.1",
|
||||
"bulma-responsive-tables": "^1.2.3",
|
||||
"bulma-switch-control": "^1.1.1",
|
||||
"bulma-upload-control": "^1.2.0",
|
||||
"node-sass": "^7.0.1"
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
|
||||
/* Aside: submenus toggle */
|
||||
Array.from(document.getElementsByClassName('menu is-menu-main')).forEach(el => {
|
||||
Array.from(el.getElementsByClassName('has-dropdown-icon')).forEach(elA => {
|
||||
elA.addEventListener('click', e => {
|
||||
const dropdownIcon = e.currentTarget
|
||||
.getElementsByClassName('dropdown-icon')[0]
|
||||
.getElementsByClassName('mdi')[0]
|
||||
|
||||
e.currentTarget.parentNode.classList.toggle('is-active')
|
||||
dropdownIcon.classList.toggle('mdi-plus')
|
||||
dropdownIcon.classList.toggle('mdi-minus')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/* Aside Mobile toggle */
|
||||
Array.from(document.getElementsByClassName('jb-aside-mobile-toggle')).forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
const dropdownIcon = e.currentTarget
|
||||
.getElementsByClassName('icon')[0]
|
||||
.getElementsByClassName('mdi')[0]
|
||||
|
||||
document.documentElement.classList.toggle('has-aside-mobile-expanded')
|
||||
dropdownIcon.classList.toggle('mdi-forwardburger')
|
||||
dropdownIcon.classList.toggle('mdi-backburger')
|
||||
})
|
||||
})
|
||||
|
||||
/* NavBar menu mobile toggle */
|
||||
Array.from(document.getElementsByClassName('jb-navbar-menu-toggle')).forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
const dropdownIcon = e.currentTarget
|
||||
.getElementsByClassName('icon')[0]
|
||||
.getElementsByClassName('mdi')[0]
|
||||
|
||||
document.getElementById(e.currentTarget.getAttribute('data-target')).classList.toggle('is-active')
|
||||
dropdownIcon.classList.toggle('mdi-dots-vertical')
|
||||
dropdownIcon.classList.toggle('mdi-close')
|
||||
})
|
||||
})
|
||||
|
||||
/* Modal: open */
|
||||
Array.from(document.getElementsByClassName('jb-modal')).forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
const modalTarget = e.currentTarget.getAttribute('data-target')
|
||||
|
||||
document.getElementById(modalTarget).classList.add('is-active')
|
||||
document.documentElement.classList.add('is-clipped')
|
||||
})
|
||||
});
|
||||
|
||||
/* Modal: close */
|
||||
Array.from(document.getElementsByClassName('jb-modal-close')).forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.currentTarget.closest('.modal').classList.remove('is-active')
|
||||
document.documentElement.classList.remove('is-clipped')
|
||||
})
|
||||
})
|
||||
|
||||
/* Notification dismiss */
|
||||
Array.from(document.getElementsByClassName('jb-notification-dismiss')).forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.currentTarget.closest('.notification').classList.add('is-hidden')
|
||||
})
|
||||
})
|
||||
@@ -1,161 +0,0 @@
|
||||
@include desktop {
|
||||
html {
|
||||
&.has-aside-left {
|
||||
&.has-aside-expanded {
|
||||
nav.navbar, body {
|
||||
padding-left: $aside-width;
|
||||
}
|
||||
}
|
||||
nav.navbar, body {
|
||||
@include transition(padding-left);
|
||||
}
|
||||
aside.is-placed-left {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aside.aside.is-expanded {
|
||||
width: $aside-width;
|
||||
|
||||
.menu-list {
|
||||
@include icon-with-update-mark($aside-icon-width);
|
||||
|
||||
span.menu-item-label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
li.is-active {
|
||||
ul {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aside.aside {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
box-shadow: $aside-box-shadow;
|
||||
background: $aside-background-color;
|
||||
|
||||
.aside-tools {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
background-color: $aside-tools-background-color;
|
||||
color: $aside-tools-color;
|
||||
line-height: $navbar-height;
|
||||
height: $navbar-height;
|
||||
padding-left: $default-padding * .5;
|
||||
flex: 1;
|
||||
|
||||
.icon {
|
||||
margin-right: $default-padding * .5;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
a {
|
||||
&.has-dropdown-icon {
|
||||
position: relative;
|
||||
padding-right: $aside-icon-width;
|
||||
|
||||
.dropdown-icon {
|
||||
position: absolute;
|
||||
top: $size-base * .5;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
ul {
|
||||
display: none;
|
||||
border-left: 0;
|
||||
background-color: darken($base-color, 2.5%);
|
||||
padding-left: 0;
|
||||
margin: 0 0 $default-padding * .5;
|
||||
|
||||
li {
|
||||
a {
|
||||
padding: $default-padding * .5 0 $default-padding * .5 $default-padding * .5;
|
||||
font-size: $aside-submenu-font-size;
|
||||
|
||||
&.has-icon {
|
||||
padding-left: 0;
|
||||
}
|
||||
&.is-active {
|
||||
&:not(:hover) {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
padding: 0 $default-padding * .5;
|
||||
margin-top: $default-padding * .5;
|
||||
margin-bottom: $default-padding * .5;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@include touch {
|
||||
#app, nav.navbar {
|
||||
@include transition(margin-left);
|
||||
}
|
||||
aside.aside {
|
||||
@include transition(left);
|
||||
}
|
||||
html.has-aside-mobile-transition {
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body, #app, nav.navbar {
|
||||
width: 100vw;
|
||||
}
|
||||
aside.aside {
|
||||
width: $aside-mobile-width;
|
||||
display: block;
|
||||
left: $aside-mobile-width * -1;
|
||||
|
||||
.image {
|
||||
img {
|
||||
max-width: $aside-mobile-width * .33;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
li.is-active {
|
||||
ul {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
a {
|
||||
@include icon-with-update-mark($aside-icon-width);
|
||||
|
||||
span.menu-item-label {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
html.has-aside-mobile-expanded {
|
||||
#app, nav.navbar {
|
||||
margin-left: $aside-mobile-width;
|
||||
}
|
||||
aside.aside {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.card:not(:last-child) {
|
||||
margin-bottom: $default-padding;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: $radius-large;
|
||||
border: $card-border;
|
||||
|
||||
&.has-table {
|
||||
.card-content {
|
||||
padding: 0;
|
||||
}
|
||||
.b-table {
|
||||
border-radius: $radius-large;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-card-widget {
|
||||
.card-content {
|
||||
padding: $default-padding * .5;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-bottom: 1px solid $base-color-light;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
hr {
|
||||
margin-left: $card-content-padding * -1;
|
||||
margin-right: $card-content-padding * -1;
|
||||
}
|
||||
}
|
||||
|
||||
.is-widget-icon {
|
||||
.icon {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.is-widget-label {
|
||||
.subtitle {
|
||||
color: $grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
footer.footer {
|
||||
.logo {
|
||||
img {
|
||||
width: auto;
|
||||
height: $footer-logo-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.footer-copyright {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
.field {
|
||||
&.has-check {
|
||||
.field-body {
|
||||
margin-top: $default-padding * .125;
|
||||
}
|
||||
}
|
||||
.control {
|
||||
.mdi-24px.mdi-set, .mdi-24px.mdi:before {
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
.upload {
|
||||
.upload-draggable {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.input, .textarea, select {
|
||||
box-shadow: none;
|
||||
|
||||
&:focus, &:active {
|
||||
box-shadow: none!important;
|
||||
}
|
||||
}
|
||||
|
||||
.switch input[type=checkbox]+.check:before {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.switch, .b-checkbox.checkbox {
|
||||
input[type=checkbox] {
|
||||
&:focus + .check, &:focus:checked + .check {
|
||||
box-shadow: none!important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox], .b-radio.radio input[type=radio] {
|
||||
&+.check {
|
||||
border: $checkbox-border;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
section.hero.is-hero-bar {
|
||||
background-color: $hero-bar-background;
|
||||
border-bottom: $light-border;
|
||||
|
||||
.hero-body {
|
||||
padding: $default-padding;
|
||||
|
||||
.level-item {
|
||||
&.is-hero-avatar-item {
|
||||
margin-right: $default-padding;
|
||||
}
|
||||
|
||||
> div > .level {
|
||||
margin-bottom: $default-padding * .5;
|
||||
}
|
||||
|
||||
.subtitle + p {
|
||||
margin-top: $default-padding * .5;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
&.is-hero-button {
|
||||
background-color: rgba($white, .5);
|
||||
font-weight: 300;
|
||||
@include transition(background-color);
|
||||
|
||||
&:hover {
|
||||
background-color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
section.section.is-main-section {
|
||||
padding-top: $default-padding;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
.is-user-avatar {
|
||||
&.has-max-width {
|
||||
max-width: $size-base * 7;
|
||||
}
|
||||
|
||||
&.is-aligned-center {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
img {
|
||||
margin: 0 auto;
|
||||
border-radius: $radius-rounded;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.has-update-mark {
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
width: $icon-update-mark-size;
|
||||
height: $icon-update-mark-size;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 1px;
|
||||
background-color: $icon-update-mark-color;
|
||||
border-radius: $radius-rounded;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
@mixin transition($t) {
|
||||
transition: $t 250ms ease-in-out 50ms;
|
||||
}
|
||||
|
||||
@mixin icon-with-update-mark ($icon-base-width) {
|
||||
.icon {
|
||||
width: $icon-base-width;
|
||||
|
||||
&.has-update-mark:after {
|
||||
right: ($icon-base-width / 2) - .85;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.modal-card {
|
||||
width: $modal-card-width;
|
||||
}
|
||||
|
||||
.modal-card-foot {
|
||||
background-color: $modal-card-foot-background-color;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.modal .modal-card {
|
||||
width: $modal-card-width-mobile;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
nav.navbar {
|
||||
box-shadow: $navbar-box-shadow;
|
||||
|
||||
.navbar-item {
|
||||
&.has-user-avatar {
|
||||
.is-user-avatar {
|
||||
margin-right: $default-padding * .5;
|
||||
display: inline-flex;
|
||||
width: $navbar-avatar-size;
|
||||
height: $navbar-avatar-size;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-divider {
|
||||
border-right: $navbar-divider-border;
|
||||
}
|
||||
|
||||
&.no-left-space {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.has-dropdown {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
|
||||
.navbar-link {
|
||||
padding-right: $navbar-item-h-padding;
|
||||
padding-left: $navbar-item-h-padding;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-control {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.control {
|
||||
.input {
|
||||
color: $navbar-input-color;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: $navbar-input-placeholder-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include touch {
|
||||
nav.navbar {
|
||||
display: flex;
|
||||
padding-right: 0;
|
||||
|
||||
.navbar-brand {
|
||||
flex: 1;
|
||||
|
||||
&.is-right {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-item {
|
||||
&.no-left-space-touch {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
padding-top: 0;
|
||||
top: $navbar-height;
|
||||
left: 0;
|
||||
|
||||
.navbar-item {
|
||||
.icon:first-child {
|
||||
margin-right: $default-padding * .5;
|
||||
}
|
||||
|
||||
&.has-dropdown {
|
||||
>.navbar-link {
|
||||
background-color: $white-ter;
|
||||
.icon:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.has-user-avatar {
|
||||
>.navbar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: $default-padding * .5;
|
||||
padding-bottom: $default-padding * .5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
nav.navbar {
|
||||
.navbar-item {
|
||||
padding-right: $navbar-item-h-padding;
|
||||
padding-left: $navbar-item-h-padding;
|
||||
|
||||
&:not(.is-desktop-icon-only) {
|
||||
.icon:first-child {
|
||||
margin-right: $default-padding * .5;
|
||||
}
|
||||
}
|
||||
&.is-desktop-icon-only {
|
||||
span:not(.icon) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
table.table {
|
||||
thead {
|
||||
th {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
td, th {
|
||||
&.checkbox-cell {
|
||||
.b-checkbox.checkbox:not(.button) {
|
||||
margin-right: 0;
|
||||
width: 20px;
|
||||
|
||||
.control-label {
|
||||
display: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
.image {
|
||||
margin: 0 auto;
|
||||
width: $table-avatar-size;
|
||||
height: $table-avatar-size;
|
||||
}
|
||||
|
||||
&.is-progress-col {
|
||||
min-width: 5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.b-table {
|
||||
.table {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* This stylizes buefy's pagination */
|
||||
.table-wrapper {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-wrapper + .level {
|
||||
padding: $notification-padding;
|
||||
padding-left: $card-content-padding;
|
||||
padding-right: $card-content-padding;
|
||||
margin: 0;
|
||||
border-top: $base-color-light;
|
||||
background: $notification-background-color;
|
||||
|
||||
.pagination-link {
|
||||
background: $button-background-color;
|
||||
color: $button-color;
|
||||
border-color: $button-border-color;
|
||||
|
||||
&.is-current {
|
||||
border-color: $button-active-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-previous, .pagination-next, .pagination-link {
|
||||
border-color: $button-border-color;
|
||||
color: $base-color;
|
||||
|
||||
&[disabled] {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.card {
|
||||
&.has-table {
|
||||
.b-table {
|
||||
.table-wrapper + .level {
|
||||
.level-left + .level-right {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&.has-mobile-sort-spaced {
|
||||
.b-table {
|
||||
.field.table-mobile-sort {
|
||||
padding-top: $default-padding * .5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.b-table {
|
||||
.field.table-mobile-sort {
|
||||
padding: 0 $default-padding * .5;
|
||||
}
|
||||
|
||||
.table-wrapper.has-mobile-cards {
|
||||
tr {
|
||||
box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1);
|
||||
margin-bottom: 3px!important;
|
||||
}
|
||||
td {
|
||||
&.is-progress-col {
|
||||
span, progress {
|
||||
display: flex;
|
||||
width: 45%;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
&.checkbox-cell, &.is-image-cell {
|
||||
border-bottom: 0!important;
|
||||
}
|
||||
|
||||
&.checkbox-cell, &.is-actions-cell {
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-no-head-mobile {
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.is-progress-col {
|
||||
progress {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-image-cell {
|
||||
.image {
|
||||
width: $table-avatar-size-mobile;
|
||||
height: auto;
|
||||
margin: 0 auto $default-padding * .25;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/* Firezone theme, using palette from https://app.getparade.com/firezoneinc/style-guide */
|
||||
|
||||
/* Common variables */
|
||||
$primary-main: #FF7300;
|
||||
$primary-000: #331700;
|
||||
$primary-100: #5c2900;
|
||||
$primary-200: #7f3900;
|
||||
$primary-300: #c25700;
|
||||
$primary-400: #ff7605;
|
||||
$primary-500: #ff9a47;
|
||||
$primary-600: #ffbc85;
|
||||
$primary-700: #ffddc2;
|
||||
$primary-800: #fff1e5;
|
||||
$primary-900: #fff9f5;
|
||||
$accent-main: #5E00D6;
|
||||
$accent-000: #160033;
|
||||
$accent-100: #28005c;
|
||||
$accent-200: #37007f;
|
||||
$accent-300: #3400c2;
|
||||
$accent-400: #4805ff;
|
||||
$accent-500: #7847ff;
|
||||
$accent-600: #a585ff;
|
||||
$accent-700: #d2c2ff;
|
||||
$accent-800: #ece5ff;
|
||||
$accent-900: #f8f5ff;
|
||||
$interface-main: #1B140E;
|
||||
$interface-000: #1b140e;
|
||||
$interface-100: #4c3e33;
|
||||
$interface-200: #766a60;
|
||||
$interface-300: #90867f;
|
||||
$interface-400: #a7a3a0;
|
||||
$interface-500: #c7c4c2;
|
||||
$interface-600: #dfdedd;
|
||||
$interface-700: #ebebea;
|
||||
$interface-800: #f8f7f7;
|
||||
$interface-900: #fcfcfc;
|
||||
|
||||
/* We'll need some initial vars to use here */
|
||||
@import "node_modules/bulma/sass/utilities/initial-variables";
|
||||
|
||||
/* Base: Size */
|
||||
$size-base: 1rem;
|
||||
$default-padding: $size-base * 1.5;
|
||||
|
||||
/* Default font */
|
||||
$family-sans-serif: "Fira Sans", sans-serif;
|
||||
$family-primary: $family-sans-serif;
|
||||
|
||||
/* Monospace font */
|
||||
$family-monospace: "Fira Mono", monospace;
|
||||
|
||||
/* Text color */
|
||||
$text: $interface-100;
|
||||
$text-light: $interface-200;
|
||||
$text-strong: $interface-000;
|
||||
|
||||
/* Base color */
|
||||
$base-color: $interface-main;
|
||||
$base-color-light: rgba(24, 28, 33, .06);
|
||||
|
||||
/* General overrides */
|
||||
/* See https://coolors.co/331700-990c00-ff0000 */
|
||||
$info: $accent-300;
|
||||
$primary: $accent-main;
|
||||
$success: #80B900;
|
||||
$warning: #FFB900;
|
||||
$danger: #990C00;
|
||||
$body-background-color: $interface-800;
|
||||
$link: $accent-300;
|
||||
$link-visited: $accent-600;
|
||||
$light-border: 1px solid $base-color-light;
|
||||
$hr-height: 1px;
|
||||
|
||||
/* NavBar: specifics */
|
||||
$navbar-input-color: $interface-100;
|
||||
$navbar-input-placeholder-color: $interface-600;
|
||||
$navbar-box-shadow: 0 1px 0 rgba(24,28,33,.04);
|
||||
$navbar-divider-border: 1px solid rgba($interface-600, .25);
|
||||
$navbar-item-h-padding: $default-padding * .75;
|
||||
$navbar-avatar-size: 1.75rem;
|
||||
|
||||
/* Aside: Bulma override */
|
||||
$menu-item-radius: 0;
|
||||
$menu-list-link-padding: $size-base * .5 0;
|
||||
$menu-label-color: $interface-300;
|
||||
$menu-item-color: $interface-400;
|
||||
$menu-item-hover-color: $interface-800;
|
||||
$menu-item-hover-background-color: darken($base-color, 3.5%);
|
||||
$menu-item-active-color: $interface-800;
|
||||
$menu-item-active-background-color: darken($base-color, 2.5%);
|
||||
|
||||
/* Aside: specifics */
|
||||
$aside-width: $size-base * 14;
|
||||
$aside-mobile-width: $size-base * 15;
|
||||
$aside-icon-width: $size-base * 3;
|
||||
$aside-submenu-font-size: $size-base * .95;
|
||||
$aside-box-shadow: none;
|
||||
$aside-background-color: $base-color;
|
||||
$aside-tools-background-color: darken($aside-background-color, 10%);
|
||||
$aside-tools-color: $interface-800;
|
||||
|
||||
/* Title Bar: specifics */
|
||||
$title-bar-color: $interface-400;
|
||||
$title-bar-active-color: $interface-100;
|
||||
|
||||
/* Hero Bar: specifics */
|
||||
$hero-bar-background: $interface-800;
|
||||
|
||||
/* Card: Bulma override */
|
||||
$card-shadow: none;
|
||||
$card-header-shadow: none;
|
||||
|
||||
/* Card: specifics */
|
||||
$card-border: 1px solid $base-color-light;
|
||||
$card-header-border-bottom-color: $base-color-light;
|
||||
|
||||
/* Table: Bulma override */
|
||||
$table-cell-border: 1px solid $interface-700;
|
||||
|
||||
/* Table: specifics */
|
||||
$table-avatar-size: $size-base * 1.5;
|
||||
$table-avatar-size-mobile: 25vw;
|
||||
|
||||
/* Form */
|
||||
$checkbox-border: 1px solid $base-color;
|
||||
|
||||
/* Modal card: Bulma override */
|
||||
$modal-card-head-background-color: $interface-800;
|
||||
$modal-card-title-size: $size-base;
|
||||
$modal-card-body-padding: $default-padding 20px;
|
||||
$modal-card-head-border-bottom: 1px solid $interface-800;
|
||||
$modal-card-foot-border-top: 0;
|
||||
|
||||
/* Modal card: specifics */
|
||||
$modal-card-width: 40vw;
|
||||
$modal-card-width-mobile: 90vw;
|
||||
$modal-card-foot-background-color: $interface-800;
|
||||
|
||||
/* Notification: Bulma override */
|
||||
$notification-padding: $default-padding * .75 $default-padding;
|
||||
|
||||
/* Footer: Bulma override */
|
||||
$footer-background-color: $white;
|
||||
$footer-padding: $default-padding * .33 $default-padding;
|
||||
|
||||
/* Footer: specifics */
|
||||
$footer-logo-height: $size-base * 2;
|
||||
|
||||
/* Progress: Bulma override */
|
||||
$progress-bar-background-color: $interface-600;
|
||||
|
||||
/* Icon: specifics */
|
||||
$icon-update-mark-size: $size-base * .5;
|
||||
$icon-update-mark-color: $primary-main;
|
||||
@@ -1,3 +0,0 @@
|
||||
.is-tiles-wrapper {
|
||||
margin-bottom: $default-padding;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
section.section.is-title-bar {
|
||||
padding: $default-padding;
|
||||
border-bottom: $light-border;
|
||||
|
||||
ul {
|
||||
li {
|
||||
display: inline-block;
|
||||
padding: 0 $default-padding * .5 0 0;
|
||||
font-size: $default-padding;
|
||||
color: $title-bar-color;
|
||||
|
||||
&:after {
|
||||
display: inline-block;
|
||||
content: '/';
|
||||
padding-left: $default-padding * .5;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
font-weight: 900;
|
||||
color: $title-bar-active-color;
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
@import "node_modules/bulma-radio/bulma-radio";
|
||||
@import "node_modules/bulma-responsive-tables/bulma-responsive-tables";
|
||||
@import "node_modules/bulma-checkbox/bulma-checkbox";
|
||||
@import "node_modules/bulma-switch-control/bulma-switch-control";
|
||||
@import "node_modules/bulma-upload-control/bulma-upload-control";
|
||||
|
||||
/* Bulma */
|
||||
@import "node_modules/bulma/bulma";
|
||||
@@ -1,22 +0,0 @@
|
||||
/* Theme style (colors & sizes) */
|
||||
@import "theme-firezone";
|
||||
|
||||
/* Core Libs & Lib configs */
|
||||
@import "libs/all";
|
||||
|
||||
/* Mixins */
|
||||
@import "mixins";
|
||||
|
||||
/* Theme components */
|
||||
@import "nav-bar";
|
||||
@import "aside";
|
||||
@import "title-bar";
|
||||
@import "hero-bar";
|
||||
@import "card";
|
||||
@import "table";
|
||||
@import "tiles";
|
||||
@import "form";
|
||||
@import "main-section";
|
||||
@import "modal";
|
||||
@import "footer";
|
||||
@import "misc";
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "firezone",
|
||||
"version": "0.1.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/firezone/firezone.git"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"deploy": "node esbuild.js prod",
|
||||
"watch": "node esbuild.js dev"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"buffer": "^6.0.3",
|
||||
"buffer-from": "^1.1.1",
|
||||
"glob": "^7.1.7",
|
||||
"highlight.js": "^11.4.0",
|
||||
"phoenix": "file:../../../deps/phoenix",
|
||||
"phoenix_html": "file:../../../deps/phoenix_html",
|
||||
"phoenix_live_view": "file:../../../deps/phoenix_live_view",
|
||||
"qrcode": "^1.3.3",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@creativebulma/bulma-tooltip": "^1.2.0",
|
||||
"@fontsource/fira-mono": "^4.5.1",
|
||||
"@fontsource/fira-sans": "^4.5.1",
|
||||
"@fontsource/open-sans": "^4.5.3",
|
||||
"@fortawesome/fontawesome-free": "^5.15.3",
|
||||
"@mdi/font": "^6.5.95",
|
||||
"admin-one-bulma-dashboard": "file:local_modules/admin-one-bulma-dashboard",
|
||||
"esbuild": "^0.14.43",
|
||||
"esbuild-sass-plugin": "^2.2.6",
|
||||
"zxcvbn": "^4.4.2"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('autoprefixer'),
|
||||
]
|
||||
}
|
||||
26
apps/web/assets/tailwind.config.js
Normal file
26
apps/web/assets/tailwind.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// See the Tailwind configuration guide for advanced usage
|
||||
// https://tailwindcss.com/docs/configuration
|
||||
|
||||
const plugin = require("tailwindcss/plugin")
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
"./js/**/*.js",
|
||||
"../lib/*_web.ex",
|
||||
"../lib/*_web/**/*.*ex"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#FD4F00",
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/forms"),
|
||||
plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
|
||||
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 &"]))
|
||||
]
|
||||
}
|
||||
165
apps/web/assets/vendor/topbar.js
vendored
Normal file
165
apps/web/assets/vendor/topbar.js
vendored
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @license MIT
|
||||
* topbar 2.0.0, 2023-02-04
|
||||
* https://buunguyen.github.io/topbar
|
||||
* Copyright (c) 2021 Buu Nguyen
|
||||
*/
|
||||
(function (window, document) {
|
||||
"use strict";
|
||||
|
||||
// https://gist.github.com/paulirish/1579671
|
||||
(function () {
|
||||
var lastTime = 0;
|
||||
var vendors = ["ms", "moz", "webkit", "o"];
|
||||
for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
|
||||
window.requestAnimationFrame =
|
||||
window[vendors[x] + "RequestAnimationFrame"];
|
||||
window.cancelAnimationFrame =
|
||||
window[vendors[x] + "CancelAnimationFrame"] ||
|
||||
window[vendors[x] + "CancelRequestAnimationFrame"];
|
||||
}
|
||||
if (!window.requestAnimationFrame)
|
||||
window.requestAnimationFrame = function (callback, element) {
|
||||
var currTime = new Date().getTime();
|
||||
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
|
||||
var id = window.setTimeout(function () {
|
||||
callback(currTime + timeToCall);
|
||||
}, timeToCall);
|
||||
lastTime = currTime + timeToCall;
|
||||
return id;
|
||||
};
|
||||
if (!window.cancelAnimationFrame)
|
||||
window.cancelAnimationFrame = function (id) {
|
||||
clearTimeout(id);
|
||||
};
|
||||
})();
|
||||
|
||||
var canvas,
|
||||
currentProgress,
|
||||
showing,
|
||||
progressTimerId = null,
|
||||
fadeTimerId = null,
|
||||
delayTimerId = null,
|
||||
addEvent = function (elem, type, handler) {
|
||||
if (elem.addEventListener) elem.addEventListener(type, handler, false);
|
||||
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
|
||||
else elem["on" + type] = handler;
|
||||
},
|
||||
options = {
|
||||
autoRun: true,
|
||||
barThickness: 3,
|
||||
barColors: {
|
||||
0: "rgba(26, 188, 156, .9)",
|
||||
".25": "rgba(52, 152, 219, .9)",
|
||||
".50": "rgba(241, 196, 15, .9)",
|
||||
".75": "rgba(230, 126, 34, .9)",
|
||||
"1.0": "rgba(211, 84, 0, .9)",
|
||||
},
|
||||
shadowBlur: 10,
|
||||
shadowColor: "rgba(0, 0, 0, .6)",
|
||||
className: null,
|
||||
},
|
||||
repaint = function () {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = options.barThickness * 5; // need space for shadow
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.shadowBlur = options.shadowBlur;
|
||||
ctx.shadowColor = options.shadowColor;
|
||||
|
||||
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||
for (var stop in options.barColors)
|
||||
lineGradient.addColorStop(stop, options.barColors[stop]);
|
||||
ctx.lineWidth = options.barThickness;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, options.barThickness / 2);
|
||||
ctx.lineTo(
|
||||
Math.ceil(currentProgress * canvas.width),
|
||||
options.barThickness / 2
|
||||
);
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.stroke();
|
||||
},
|
||||
createCanvas = function () {
|
||||
canvas = document.createElement("canvas");
|
||||
var style = canvas.style;
|
||||
style.position = "fixed";
|
||||
style.top = style.left = style.right = style.margin = style.padding = 0;
|
||||
style.zIndex = 100001;
|
||||
style.display = "none";
|
||||
if (options.className) canvas.classList.add(options.className);
|
||||
document.body.appendChild(canvas);
|
||||
addEvent(window, "resize", repaint);
|
||||
},
|
||||
topbar = {
|
||||
config: function (opts) {
|
||||
for (var key in opts)
|
||||
if (options.hasOwnProperty(key)) options[key] = opts[key];
|
||||
},
|
||||
show: function (delay) {
|
||||
if (showing) return;
|
||||
if (delay) {
|
||||
if (delayTimerId) return;
|
||||
delayTimerId = setTimeout(() => topbar.show(), delay);
|
||||
} else {
|
||||
showing = true;
|
||||
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
|
||||
if (!canvas) createCanvas();
|
||||
canvas.style.opacity = 1;
|
||||
canvas.style.display = "block";
|
||||
topbar.progress(0);
|
||||
if (options.autoRun) {
|
||||
(function loop() {
|
||||
progressTimerId = window.requestAnimationFrame(loop);
|
||||
topbar.progress(
|
||||
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
|
||||
);
|
||||
})();
|
||||
}
|
||||
}
|
||||
},
|
||||
progress: function (to) {
|
||||
if (typeof to === "undefined") return currentProgress;
|
||||
if (typeof to === "string") {
|
||||
to =
|
||||
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
|
||||
? currentProgress
|
||||
: 0) + parseFloat(to);
|
||||
}
|
||||
currentProgress = to > 1 ? 1 : to;
|
||||
repaint();
|
||||
return currentProgress;
|
||||
},
|
||||
hide: function () {
|
||||
clearTimeout(delayTimerId);
|
||||
delayTimerId = null;
|
||||
if (!showing) return;
|
||||
showing = false;
|
||||
if (progressTimerId != null) {
|
||||
window.cancelAnimationFrame(progressTimerId);
|
||||
progressTimerId = null;
|
||||
}
|
||||
(function loop() {
|
||||
if (topbar.progress("+.1") >= 1) {
|
||||
canvas.style.opacity -= 0.05;
|
||||
if (canvas.style.opacity <= 0.05) {
|
||||
canvas.style.display = "none";
|
||||
fadeTimerId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
fadeTimerId = window.requestAnimationFrame(loop);
|
||||
})();
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module === "object" && typeof module.exports === "object") {
|
||||
module.exports = topbar;
|
||||
} else if (typeof define === "function" && define.amd) {
|
||||
define(function () {
|
||||
return topbar;
|
||||
});
|
||||
} else {
|
||||
this.topbar = topbar;
|
||||
}
|
||||
}.call(this, window, document));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"skip_files": [
|
||||
"test"
|
||||
]
|
||||
}
|
||||
@@ -1,96 +1,29 @@
|
||||
defmodule Web do
|
||||
@moduledoc """
|
||||
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:
|
||||
|
||||
use Web, :controller
|
||||
use Web, :view
|
||||
use Web, :html
|
||||
|
||||
The definitions below will be executed for every view,
|
||||
controller, etc, so keep them short and clean, focused
|
||||
The definitions below will be executed for every controller,
|
||||
component, etc, so keep them short and clean, focused
|
||||
on imports, uses and aliases.
|
||||
|
||||
Do NOT define functions inside the quoted expressions
|
||||
below. Instead, define any helper function in modules
|
||||
and import those modules here.
|
||||
below. Instead, define additional modules and import
|
||||
those modules here.
|
||||
"""
|
||||
|
||||
def controller do
|
||||
quote do
|
||||
use Phoenix.Controller, namespace: Web
|
||||
|
||||
import Plug.Conn
|
||||
import Web.Gettext
|
||||
import Phoenix.LiveView.Controller
|
||||
import Web.ControllerHelpers
|
||||
import Web.DocHelpers
|
||||
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def view do
|
||||
quote do
|
||||
use Phoenix.View,
|
||||
root: "lib/web/templates",
|
||||
namespace: Web
|
||||
|
||||
# Import convenience functions from controllers
|
||||
import Phoenix.Controller, only: [view_module: 1]
|
||||
|
||||
# Use all HTML functionality (forms, tags, etc)
|
||||
use Phoenix.HTML
|
||||
|
||||
# Use all LiveView functionality
|
||||
use Phoenix.Component, global_prefixes: ~w(x-)
|
||||
|
||||
import Web.ErrorHelpers
|
||||
import Web.AuthorizationHelpers
|
||||
import Web.Gettext
|
||||
import Web.LiveHelpers
|
||||
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def live_view do
|
||||
quote do
|
||||
use Phoenix.LiveView, layout: {Web.LayoutView, :live}
|
||||
import Web.LiveHelpers
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
unquote(view_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def live_view_without_layout do
|
||||
quote do
|
||||
use Phoenix.LiveView
|
||||
import Web.LiveHelpers
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
unquote(view_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def live_component do
|
||||
quote do
|
||||
import Phoenix.LiveView
|
||||
use Phoenix.LiveComponent
|
||||
use Phoenix.Component, global_prefixes: ~w(x-)
|
||||
import Web.LiveHelpers
|
||||
|
||||
unquote(view_helpers())
|
||||
end
|
||||
end
|
||||
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
|
||||
|
||||
def router do
|
||||
quote do
|
||||
use Phoenix.Router
|
||||
use Phoenix.Router, helpers: false
|
||||
|
||||
# Import common connection and controller functions to use in pipelines
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
import Phoenix.LiveView.Router
|
||||
@@ -100,38 +33,68 @@ defmodule Web do
|
||||
def channel do
|
||||
quote do
|
||||
use Phoenix.Channel
|
||||
import Web.Gettext
|
||||
end
|
||||
end
|
||||
|
||||
def helper do
|
||||
def controller do
|
||||
quote do
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
use Phoenix.Controller,
|
||||
formats: [:html, :json],
|
||||
layouts: [html: Web.Layouts]
|
||||
|
||||
defp view_helpers do
|
||||
quote do
|
||||
# Use all HTML functionality (forms, tags, etc)
|
||||
use Phoenix.HTML
|
||||
|
||||
# Import LiveView helpers (live_render, live_component, live_patch, etc)
|
||||
import Phoenix.Component
|
||||
|
||||
# Import basic rendering functionality (render, render_layout, etc)
|
||||
import Phoenix.View
|
||||
|
||||
# Authorization Helpers
|
||||
import Web.AuthorizationHelpers
|
||||
|
||||
import Web.ErrorHelpers
|
||||
import Plug.Conn
|
||||
import Web.Gettext
|
||||
import Web.ControllerDocumentation
|
||||
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def static_paths, do: ~w(dist fonts images uploads robots.txt)
|
||||
def live_view do
|
||||
quote do
|
||||
use Phoenix.LiveView,
|
||||
layout: {Web.Layouts, :app}
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def live_component do
|
||||
quote do
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def html do
|
||||
quote do
|
||||
use Phoenix.Component
|
||||
|
||||
# Import convenience functions from controllers
|
||||
import Phoenix.Controller,
|
||||
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
|
||||
|
||||
# Include general helpers for rendering HTML
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
defp html_helpers do
|
||||
quote do
|
||||
# HTML escaping functionality
|
||||
import Phoenix.HTML
|
||||
# Core UI components and translation
|
||||
import Web.CoreComponents
|
||||
import Web.Gettext
|
||||
|
||||
# Shortcut for generating JS commands
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
# Routes generation with the ~p sigil
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def verified_routes do
|
||||
quote do
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
defmodule Web.Application do
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
Supervisor.start_link(children(), strategy: :one_for_one, name: __MODULE__.Supervisor)
|
||||
children = [
|
||||
Web.Telemetry,
|
||||
{Phoenix.PubSub, name: Web.PubSub},
|
||||
Web.Endpoint
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: Web.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def config_change(changed, _new, removed) do
|
||||
Web.Endpoint.config_change(changed, removed)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp children do
|
||||
[
|
||||
Web.Presence,
|
||||
Web.Endpoint
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
defmodule Web.Auth.HTML.Authentication do
|
||||
@moduledoc """
|
||||
HTML Authentication implementation module for Guardian.
|
||||
"""
|
||||
use Guardian, otp_app: :web
|
||||
use Web, :controller
|
||||
alias Domain.Auth
|
||||
alias Domain.Telemetry
|
||||
alias Domain.Users
|
||||
alias Domain.Users.User
|
||||
|
||||
@guardian_token_name "guardian_default_token"
|
||||
|
||||
@impl Guardian
|
||||
def subject_for_token(%Auth.Subject{actor: {:user, user}}, _claims) do
|
||||
{:ok, user.id}
|
||||
end
|
||||
|
||||
@impl Guardian
|
||||
def resource_from_claims(%{"sub" => id}) do
|
||||
with {:ok, user} <- Users.fetch_user_by_id(id) do
|
||||
# XXX: Guardian doesn't allow us to access the conn params here
|
||||
subject = Auth.fetch_subject!(user, nil, nil)
|
||||
{:ok, subject}
|
||||
else
|
||||
{:error, :not_found} -> {:error, :resource_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates a user against a password hash. Only makes sense
|
||||
for local auth.
|
||||
"""
|
||||
def authenticate(%User{} = user, password) when is_binary(password) do
|
||||
if user.password_hash do
|
||||
authenticate(
|
||||
user,
|
||||
password,
|
||||
Argon2.verify_pass(password, user.password_hash)
|
||||
)
|
||||
else
|
||||
{:error, :invalid_credentials}
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate(_user, _password) do
|
||||
authenticate(nil, nil, Argon2.no_user_verify())
|
||||
end
|
||||
|
||||
defp authenticate(user, _password, true) do
|
||||
{:ok, user}
|
||||
end
|
||||
|
||||
defp authenticate(_user, _password, false) do
|
||||
{:error, :invalid_credentials}
|
||||
end
|
||||
|
||||
def sign_in(conn, user, auth) do
|
||||
Telemetry.login()
|
||||
Users.update_last_signed_in(user, auth)
|
||||
subject = Auth.fetch_subject!(user, nil, nil)
|
||||
%{provider: provider_id} = auth
|
||||
|
||||
conn
|
||||
|> Plug.Conn.put_session("login_method", provider_id)
|
||||
|> Plug.Conn.put_session("logged_in_at", DateTime.utc_now())
|
||||
|> __MODULE__.Plug.sign_in(subject)
|
||||
end
|
||||
|
||||
def sign_out(conn) do
|
||||
with provider_id when not is_nil(provider_id) <- Plug.Conn.get_session(conn, "login_method"),
|
||||
token when not is_nil(token) <- Plug.Conn.get_session(conn, "id_token"),
|
||||
{:ok, config} <- Auth.fetch_oidc_provider_config(provider_id),
|
||||
{:ok, end_session_uri} <-
|
||||
OpenIDConnect.end_session_uri(config, %{
|
||||
id_token_hint: token,
|
||||
post_logout_redirect_uri: url(~p"/")
|
||||
}) do
|
||||
conn
|
||||
|> __MODULE__.Plug.sign_out()
|
||||
|> Plug.Conn.configure_session(drop: true)
|
||||
|> Phoenix.Controller.redirect(external: end_session_uri)
|
||||
else
|
||||
_ ->
|
||||
conn
|
||||
|> __MODULE__.Plug.sign_out()
|
||||
|> Plug.Conn.configure_session(drop: true)
|
||||
|> Phoenix.Controller.redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
|
||||
def get_current_subject(%Plug.Conn{} = conn) do
|
||||
__MODULE__.Plug.current_resource(conn)
|
||||
end
|
||||
|
||||
def get_current_subject(%{@guardian_token_name => token} = _session) do
|
||||
case Guardian.resource_from_token(__MODULE__, token) do
|
||||
{:ok, resource, _claims} ->
|
||||
resource
|
||||
|
||||
{:error, _reason} ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
defmodule Web.Auth.HTML.ErrorHandler do
|
||||
@moduledoc """
|
||||
HTML Error Handler module implementation for Guardian.
|
||||
"""
|
||||
|
||||
use Web, :controller
|
||||
alias Web.Auth.HTML.Authentication
|
||||
import Web.ControllerHelpers, only: [root_path_for_user: 1]
|
||||
require Logger
|
||||
|
||||
@behaviour Guardian.Plug.ErrorHandler
|
||||
|
||||
@impl Guardian.Plug.ErrorHandler
|
||||
def auth_error(conn, {:already_authenticated, _reason}, _opts) do
|
||||
if subject = Authentication.get_current_subject(conn) do
|
||||
{:user, user} = subject.actor
|
||||
redirect(conn, to: root_path_for_user(user))
|
||||
else
|
||||
redirect(conn, to: root_path_for_user(nil))
|
||||
end
|
||||
end
|
||||
|
||||
@impl Guardian.Plug.ErrorHandler
|
||||
def auth_error(conn, {:unauthenticated, _reason}, _opts) do
|
||||
conn
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
|
||||
@impl Guardian.Plug.ErrorHandler
|
||||
def auth_error(conn, {type, reason}, _opts) do
|
||||
Logger.info("Web auth error. Type: #{type}. Reason: #{reason}.")
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(401, to_string(type))
|
||||
end
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
defmodule Web.Auth.HTML.Pipeline do
|
||||
@moduledoc """
|
||||
HTML Plug implementation module for Guardian.
|
||||
"""
|
||||
|
||||
use Guardian.Plug.Pipeline,
|
||||
otp_app: :web,
|
||||
error_handler: Web.Auth.HTML.ErrorHandler,
|
||||
module: Web.Auth.HTML.Authentication
|
||||
|
||||
@claims %{"typ" => "access"}
|
||||
|
||||
plug Guardian.Plug.VerifySession, claims: @claims, refresh_from_cookie: true
|
||||
plug Guardian.Plug.LoadResource, allow_blank: true
|
||||
end
|
||||
@@ -1,42 +0,0 @@
|
||||
defmodule Web.Auth.JSON.Authentication do
|
||||
@moduledoc """
|
||||
API Authentication implementation module for Guardian.
|
||||
"""
|
||||
use Guardian, otp_app: :web
|
||||
|
||||
alias Domain.{
|
||||
Auth,
|
||||
ApiTokens.ApiToken,
|
||||
ApiTokens
|
||||
}
|
||||
|
||||
@impl Guardian
|
||||
def subject_for_token(%Auth.Subject{actor: {:user, user}}, _claims) do
|
||||
{:ok, user.id}
|
||||
end
|
||||
|
||||
@impl Guardian
|
||||
def resource_from_claims(%{"api" => api_token_id}) do
|
||||
with {:ok, %ApiTokens.ApiToken{} = api_token} <-
|
||||
ApiTokens.fetch_unexpired_api_token_by_id(api_token_id) do
|
||||
subject = Auth.fetch_subject!(api_token, nil, nil)
|
||||
{:ok, subject}
|
||||
else
|
||||
{:error, :not_found} -> {:error, :resource_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def fz_encode_and_sign(%ApiToken{} = api_token) do
|
||||
claims = %{
|
||||
"api" => api_token.id,
|
||||
"exp" => DateTime.to_unix(api_token.expires_at)
|
||||
}
|
||||
|
||||
subject = Auth.fetch_subject!(api_token, nil, nil)
|
||||
Guardian.encode_and_sign(__MODULE__, subject, claims)
|
||||
end
|
||||
|
||||
def get_current_subject(%Plug.Conn{} = conn) do
|
||||
__MODULE__.Plug.current_resource(conn)
|
||||
end
|
||||
end
|
||||
@@ -1,18 +0,0 @@
|
||||
defmodule Web.Auth.JSON.ErrorHandler do
|
||||
@moduledoc """
|
||||
API Error Handler module implementation for Guardian.
|
||||
"""
|
||||
use Web, :controller
|
||||
require Logger
|
||||
|
||||
@behaviour Guardian.Plug.ErrorHandler
|
||||
|
||||
@impl Guardian.Plug.ErrorHandler
|
||||
def auth_error(conn, {type, reason}, _opts) do
|
||||
Logger.info("API auth error. Type: #{type}. Reason: #{reason}.")
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(401, Jason.encode!(%{"errors" => %{"auth" => to_string(type)}}))
|
||||
end
|
||||
end
|
||||
@@ -1,17 +0,0 @@
|
||||
defmodule Web.Auth.JSON.Pipeline do
|
||||
@moduledoc """
|
||||
API Plug implementation module for Guardian.
|
||||
"""
|
||||
|
||||
use Guardian.Plug.Pipeline,
|
||||
otp_app: :web,
|
||||
error_handler: Web.Auth.JSON.ErrorHandler,
|
||||
module: Web.Auth.JSON.Authentication
|
||||
|
||||
# 90 days
|
||||
@max_age 60 * 60 * 24 * 90
|
||||
|
||||
plug Guardian.Plug.VerifyHeader, max_age: @max_age
|
||||
plug Guardian.Plug.EnsureAuthenticated
|
||||
plug Guardian.Plug.LoadResource
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
defmodule Web.AuthorizationHelpers do
|
||||
@moduledoc """
|
||||
Authorization-related helpers
|
||||
"""
|
||||
use Web, :helper
|
||||
import Phoenix.LiveView
|
||||
|
||||
def not_authorized(socket) do
|
||||
socket
|
||||
|> put_flash(:error, "Not authorized.")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
@@ -1,51 +0,0 @@
|
||||
defmodule Web.NotificationChannel do
|
||||
@moduledoc """
|
||||
Handles dispatching realtime notifications to users' browser sessions.
|
||||
"""
|
||||
use Web, :channel
|
||||
alias Domain.Users
|
||||
alias Web.Presence
|
||||
|
||||
@impl Phoenix.Channel
|
||||
def join("notification:session", _attrs, socket) do
|
||||
socket = Web.Sandbox.allow_channel_sql_sandbox(socket)
|
||||
|
||||
with {:ok, user} <- Users.fetch_user_by_id(socket.assigns.current_user_id) do
|
||||
socket = assign(socket, :current_user, user)
|
||||
send(self(), :after_join)
|
||||
{:ok, socket}
|
||||
else
|
||||
_ -> {:error, %{reason: "unauthorized"}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Phoenix.Channel
|
||||
def handle_info(:after_join, socket) do
|
||||
track(socket)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp track(socket) do
|
||||
user = socket.assigns.current_user
|
||||
|
||||
tracking_info = %{
|
||||
email: user.email,
|
||||
online_at: DateTime.utc_now(),
|
||||
last_signed_in_at: user.last_signed_in_at,
|
||||
last_signed_in_method: user.last_signed_in_method,
|
||||
remote_ip: socket.assigns.remote_ip,
|
||||
user_agent: socket.assigns.user_agent
|
||||
}
|
||||
|
||||
{:ok, _} = Presence.track(socket, user.id, tracking_info)
|
||||
|
||||
push(socket, "presence_state", presence_list(socket))
|
||||
end
|
||||
|
||||
defp presence_list(socket) do
|
||||
ids_to_show = [socket.assigns.current_user.id]
|
||||
|
||||
Presence.list(socket)
|
||||
|> Map.take(ids_to_show)
|
||||
end
|
||||
end
|
||||
661
apps/web/lib/web/components/core_components.ex
Normal file
661
apps/web/lib/web/components/core_components.ex
Normal file
@@ -0,0 +1,661 @@
|
||||
defmodule Web.CoreComponents do
|
||||
@moduledoc """
|
||||
Provides core UI components.
|
||||
|
||||
The components in this module use Tailwind CSS, a utility-first CSS framework.
|
||||
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to
|
||||
customize the generated components in this module.
|
||||
|
||||
Icons are provided by [heroicons](https://heroicons.com), using the
|
||||
[heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
import Web.Gettext
|
||||
|
||||
@doc """
|
||||
Renders a modal.
|
||||
|
||||
## Examples
|
||||
|
||||
<.modal id="confirm-modal">
|
||||
Are you sure?
|
||||
<:confirm>OK</:confirm>
|
||||
<:cancel>Cancel</:cancel>
|
||||
</.modal>
|
||||
|
||||
JS commands may be passed to the `:on_cancel` and `on_confirm` attributes
|
||||
for the caller to react to each button press, for example:
|
||||
|
||||
<.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}>
|
||||
Are you sure you?
|
||||
<:confirm>OK</:confirm>
|
||||
<:cancel>Cancel</:cancel>
|
||||
</.modal>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :show, :boolean, default: false
|
||||
attr :on_cancel, JS, default: %JS{}
|
||||
attr :on_confirm, JS, default: %JS{}
|
||||
|
||||
slot :inner_block, required: true
|
||||
slot :title
|
||||
slot :subtitle
|
||||
slot :confirm
|
||||
slot :cancel
|
||||
|
||||
def modal(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
phx-mounted={@show && show_modal(@id)}
|
||||
phx-remove={hide_modal(@id)}
|
||||
class="relative z-50 hidden"
|
||||
>
|
||||
<div id={"#{@id}-bg"} class="fixed inset-0 bg-zinc-50/90 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-mounted={@show && show_modal(@id)}
|
||||
phx-window-keydown={hide_modal(@on_cancel, @id)}
|
||||
phx-key="escape"
|
||||
phx-click-away={hide_modal(@on_cancel, @id)}
|
||||
class="hidden relative rounded-2xl bg-white p-14 shadow-lg shadow-zinc-700/10 ring-1 ring-zinc-700/10 transition"
|
||||
>
|
||||
<div class="absolute top-6 right-5">
|
||||
<button
|
||||
phx-click={hide_modal(@on_cancel, @id)}
|
||||
type="button"
|
||||
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
|
||||
aria-label={gettext("close")}
|
||||
>
|
||||
<Heroicons.x_mark solid class="h-5 w-5 stroke-current" />
|
||||
</button>
|
||||
</div>
|
||||
<div id={"#{@id}-content"}>
|
||||
<header :if={@title != []}>
|
||||
<h1 id={"#{@id}-title"} class="text-lg font-semibold leading-8 text-zinc-800">
|
||||
<%= render_slot(@title) %>
|
||||
</h1>
|
||||
<p
|
||||
:if={@subtitle != []}
|
||||
id={"#{@id}-description"}
|
||||
class="mt-2 text-sm leading-6 text-zinc-600"
|
||||
>
|
||||
<%= render_slot(@subtitle) %>
|
||||
</p>
|
||||
</header>
|
||||
<%= render_slot(@inner_block) %>
|
||||
<div :if={@confirm != [] or @cancel != []} class="ml-6 mb-4 flex items-center gap-5">
|
||||
<.button
|
||||
:for={confirm <- @confirm}
|
||||
id={"#{@id}-confirm"}
|
||||
phx-click={@on_confirm}
|
||||
phx-disable-with
|
||||
class="py-2 px-3"
|
||||
>
|
||||
<%= render_slot(confirm) %>
|
||||
</.button>
|
||||
<.link
|
||||
:for={cancel <- @cancel}
|
||||
phx-click={hide_modal(@on_cancel, @id)}
|
||||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<%= render_slot(cancel) %>
|
||||
</.link>
|
||||
</div>
|
||||
</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, default: "flash", 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 :autoshow, :boolean, default: true, doc: "whether to auto show the flash on mount"
|
||||
attr :close, :boolean, default: true, doc: "whether the flash can be closed"
|
||||
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
|
||||
~H"""
|
||||
<div
|
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||
id={@id}
|
||||
phx-mounted={@autoshow && show("##{@id}")}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class={[
|
||||
"fixed hidden top-2 right-2 w-80 sm:w-96 z-50 rounded-lg p-3 shadow-md shadow-zinc-900/5 ring-1",
|
||||
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
|
||||
@kind == :error && "bg-rose-50 p-3 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<p :if={@title} class="flex items-center gap-1.5 text-[0.8125rem] font-semibold leading-6">
|
||||
<Heroicons.information_circle :if={@kind == :info} mini class="h-4 w-4" />
|
||||
<Heroicons.exclamation_circle :if={@kind == :error} mini class="h-4 w-4" />
|
||||
<%= @title %>
|
||||
</p>
|
||||
<p class="mt-2 text-[0.8125rem] leading-5"><%= msg %></p>
|
||||
<button
|
||||
:if={@close}
|
||||
type="button"
|
||||
class="group absolute top-2 right-1 p-2"
|
||||
aria-label={gettext("close")}
|
||||
>
|
||||
<Heroicons.x_mark solid class="h-5 w-5 stroke-current 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"
|
||||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<.flash kind={:info} title="Success!" flash={@flash} />
|
||||
<.flash kind={:error} title="Error!" flash={@flash} />
|
||||
<.flash
|
||||
id="disconnected"
|
||||
kind={:error}
|
||||
title="We can't find the internet"
|
||||
close={false}
|
||||
autoshow={false}
|
||||
phx-disconnected={show("#disconnected")}
|
||||
phx-connected={hide("#disconnected")}
|
||||
>
|
||||
Attempting to reconnect <Heroicons.arrow_path class="ml-1 w-3 h-3 inline animate-spin" />
|
||||
</.flash>
|
||||
"""
|
||||
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 datastructure 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),
|
||||
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="space-y-8 bg-white mt-10">
|
||||
<%= 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.Form{}` and field name may be passed to the input
|
||||
to build input names and error messages, or all the attributes and
|
||||
errors may be passed explicitly.
|
||||
|
||||
## 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 hidden month number password
|
||||
range radio 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(autocomplete cols disabled form max maxlength min minlength
|
||||
pattern placeholder readonly required rows size step)
|
||||
slot :inner_block
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(field.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", value: value} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end)
|
||||
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
|
||||
<input type="hidden" name={@name} value="false" />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id || @name}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class="rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900"
|
||||
{@rest}
|
||||
/>
|
||||
<%= @label %>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-zinc-500 focus:border-zinc-500 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 phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<textarea
|
||||
id={@id || @name}
|
||||
name={@name}
|
||||
class={[
|
||||
"mt-2 block min-h-[6rem] w-full rounded-lg border-zinc-300 py-[7px] px-[11px]",
|
||||
"text-zinc-900 focus:border-zinc-400 focus:outline-none focus:ring-4 focus:ring-zinc-800/5 sm:text-sm sm:leading-6",
|
||||
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5",
|
||||
"border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10"
|
||||
]}
|
||||
{@rest}
|
||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id || @name}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
"mt-2 block w-full rounded-lg border-zinc-300 py-[7px] px-[11px]",
|
||||
"text-zinc-900 focus:outline-none focus:ring-4 sm:text-sm sm:leading-6",
|
||||
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5",
|
||||
"border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10"
|
||||
]}
|
||||
{@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="phx-no-feedback:hidden mt-3 flex gap-3 text-sm leading-6 text-rose-600">
|
||||
<Heroicons.exclamation_circle mini class="mt-0.5 h-5 w-5 flex-none fill-rose-500" />
|
||||
<%= 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="mt-11 w-[40rem] sm:w-full">
|
||||
<thead class="text-left text-[0.8125rem] leading-6 text-zinc-500">
|
||||
<tr>
|
||||
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
|
||||
<th 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 p-0 w-14">
|
||||
<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 sm:gap-8">
|
||||
<dt class="w-1/4 flex-none text-[0.8125rem] leading-6 text-zinc-500"><%= item.title %></dt>
|
||||
<dd class="text-sm leading-6 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"
|
||||
>
|
||||
<Heroicons.arrow_left solid class="w-3 h-3 stroke-current inline" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
## JS Commands
|
||||
|
||||
def show(js \\ %JS{}, selector) do
|
||||
JS.show(js,
|
||||
to: selector,
|
||||
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",
|
||||
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 "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(Web.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(Web.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
|
||||
5
apps/web/lib/web/components/layouts.ex
Normal file
5
apps/web/lib/web/components/layouts.ex
Normal file
@@ -0,0 +1,5 @@
|
||||
defmodule Web.Layouts do
|
||||
use Web, :html
|
||||
|
||||
embed_templates "layouts/*"
|
||||
end
|
||||
43
apps/web/lib/web/components/layouts/app.html.heex
Normal file
43
apps/web/lib/web/components/layouts/app.html.heex
Normal file
@@ -0,0 +1,43 @@
|
||||
<header class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between border-b border-zinc-100 py-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/">
|
||||
<svg viewBox="0 0 71 48" class="h-6" 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>
|
||||
</a>
|
||||
<p class="rounded-full bg-brand/5 px-2 text-[0.8125rem] font-medium leading-6 text-brand">
|
||||
v1.7
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="https://twitter.com/elixirphoenix"
|
||||
class="text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
@elixirphoenix
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/phoenixframework/phoenix"
|
||||
class="text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://hexdocs.pm/phoenix/overview.html"
|
||||
class="rounded-lg bg-zinc-100 px-2 py-1 text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:bg-zinc-200/80 active:text-zinc-900/70"
|
||||
>
|
||||
Get Started <span aria-hidden="true">→</span>
|
||||
</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>
|
||||
17
apps/web/lib/web/components/layouts/root.html.heex
Normal file
17
apps/web/lib/web/components/layouts/root.html.heex
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" style="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] || "Web" %>
|
||||
</.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 antialiased">
|
||||
<%= @inner_content %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule Web.DocHelpers do
|
||||
defmodule Web.ControllerDocumentation do
|
||||
def group(name, children) do
|
||||
{:group, {name, children}}
|
||||
end
|
||||
@@ -1,20 +0,0 @@
|
||||
defmodule Web.ControllerHelpers do
|
||||
@moduledoc """
|
||||
Useful helpers for controllers
|
||||
"""
|
||||
use Web, :helper
|
||||
|
||||
alias Domain.Users.User
|
||||
|
||||
def root_path_for_user(nil) do
|
||||
~p"/"
|
||||
end
|
||||
|
||||
def root_path_for_user(%User{role: :admin}) do
|
||||
~p"/users"
|
||||
end
|
||||
|
||||
def root_path_for_user(%User{role: :unprivileged}) do
|
||||
~p"/user_devices"
|
||||
end
|
||||
end
|
||||
@@ -1,198 +0,0 @@
|
||||
defmodule Web.AuthController do
|
||||
@moduledoc """
|
||||
Implements the CRUD for a Session
|
||||
"""
|
||||
use Web, :controller
|
||||
alias Domain.Users
|
||||
alias Domain.Auth
|
||||
alias Web.Auth.HTML.Authentication
|
||||
alias Web.OAuth.PKCE
|
||||
alias Web.OIDC.State
|
||||
alias Web.UserFromAuth
|
||||
require Logger
|
||||
|
||||
# Uncomment when Helpers.callback_url/1 is fixed
|
||||
# alias Ueberauth.Strategy.Helpers
|
||||
|
||||
plug Ueberauth
|
||||
|
||||
def request(conn, _params) do
|
||||
path = ~p"/auth/identity/callback"
|
||||
|
||||
conn
|
||||
|> render("request.html", callback_path: path)
|
||||
end
|
||||
|
||||
def callback(%{assigns: %{ueberauth_failure: %{errors: errors}}} = conn, _params) do
|
||||
msg = Enum.map_join(errors, ". ", fn error -> error.message end)
|
||||
|
||||
conn
|
||||
|> put_flash(:error, msg)
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
|
||||
def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
|
||||
case UserFromAuth.find_or_create(auth) do
|
||||
{:ok, user} ->
|
||||
do_sign_in(conn, user, auth)
|
||||
|
||||
{:error, reason} when reason in [:not_found, :invalid_credentials] ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
"Error signing in: user credentials are invalid or user does not exist"
|
||||
)
|
||||
|> request(%{})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "Error signing in: #{reason}")
|
||||
|> request(%{})
|
||||
end
|
||||
end
|
||||
|
||||
# This can be called if the user attempts to visit one of the callback redirect URLs
|
||||
# directly.
|
||||
def callback(conn, params) do
|
||||
conn
|
||||
|> put_flash(:error, inspect(params) <> inspect(conn.assigns))
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
|
||||
def oidc_callback(conn, %{"provider" => provider_id, "state" => state} = params)
|
||||
when is_binary(provider_id) do
|
||||
token_params = Map.merge(params, PKCE.token_params(conn))
|
||||
|
||||
with :ok <- State.verify_state(conn, state),
|
||||
{:ok, config} <- Auth.fetch_oidc_provider_config(provider_id),
|
||||
{:ok, tokens} <- OpenIDConnect.fetch_tokens(config, token_params),
|
||||
{:ok, claims} <- OpenIDConnect.verify(config, tokens["id_token"]) do
|
||||
case UserFromAuth.find_or_create(provider_id, claims) do
|
||||
{:ok, user} ->
|
||||
# only first-time connect will include refresh token
|
||||
# XXX: Remove this when SCIM 2.0 is implemented
|
||||
with %{"refresh_token" => refresh_token} <- tokens do
|
||||
Domain.Auth.OIDC.create_connection(user.id, provider_id, refresh_token)
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_session("id_token", tokens["id_token"])
|
||||
|> do_sign_in(user, %{provider: provider_id})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "Error signing in: #{reason}")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
else
|
||||
# Error verifying state, claims or fetching tokens
|
||||
{:error, error} ->
|
||||
msg = "An OpenIDConnect error occurred. Details: #{inspect(error)}"
|
||||
Logger.error(msg)
|
||||
|
||||
conn
|
||||
|> put_flash(:error, msg)
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
|
||||
def saml_callback(conn, _params) do
|
||||
key = {idp, _} = get_session(conn, "samly_assertion_key")
|
||||
assertion = %Samly.Assertion{} = Samly.State.get_assertion(conn, key)
|
||||
|
||||
with {:ok, user} <-
|
||||
UserFromAuth.find_or_create(:saml, idp, %{"email" => assertion.subject.name}) do
|
||||
do_sign_in(conn, user, %{provider: idp})
|
||||
else
|
||||
{:error, %{errors: [email: {"is invalid email address", _metadata}]}} ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
"SAML provider did not return a valid email address in `name` assertion"
|
||||
)
|
||||
|> redirect(to: ~p"/")
|
||||
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
conn
|
||||
|> put_flash(:error, reason)
|
||||
|> redirect(to: ~p"/")
|
||||
|
||||
other ->
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
Authentication.sign_out(conn)
|
||||
end
|
||||
|
||||
def reset_password(conn, _params) do
|
||||
render(conn, "reset_password.html")
|
||||
end
|
||||
|
||||
def magic_link(conn, %{"email" => email}) do
|
||||
with {:ok, user} <- Users.fetch_user_by_email(email),
|
||||
{:ok, user} <- Users.request_sign_in_token(user) do
|
||||
Web.Mailer.AuthEmail.magic_link(user)
|
||||
|> Web.Mailer.deliver!()
|
||||
|
||||
conn
|
||||
|> put_flash(:info, "Please check your inbox for the magic link.")
|
||||
|> redirect(to: ~p"/")
|
||||
else
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_flash(:warning, "Failed to send magic link email.")
|
||||
|> redirect(to: ~p"/auth/reset_password")
|
||||
end
|
||||
end
|
||||
|
||||
def magic_sign_in(conn, %{"user_id" => user_id, "token" => token}) do
|
||||
with {:ok, user} <- Users.fetch_user_by_id(user_id),
|
||||
{:ok, _user} <- Users.consume_sign_in_token(user, token) do
|
||||
do_sign_in(conn, user, %{provider: :magic_link})
|
||||
else
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "The magic link is not valid or has expired.")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_oidc_auth_uri(conn, %{"provider" => provider_id}) when is_binary(provider_id) do
|
||||
verifier = PKCE.code_verifier()
|
||||
|
||||
params = %{
|
||||
access_type: :offline,
|
||||
state: State.new(),
|
||||
code_challenge_method: PKCE.code_challenge_method(),
|
||||
code_challenge: PKCE.code_challenge(verifier)
|
||||
}
|
||||
|
||||
with {:ok, config} <- Auth.fetch_oidc_provider_config(provider_id),
|
||||
{:ok, uri} <- OpenIDConnect.authorization_uri(config, params) do
|
||||
conn
|
||||
|> PKCE.put_cookie(verifier)
|
||||
|> State.put_cookie(params.state)
|
||||
|> redirect(external: uri)
|
||||
else
|
||||
{:error, :not_found} ->
|
||||
{:error, :not_found}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Cannot redirect user to OIDC auth uri", reason: inspect(reason))
|
||||
|
||||
conn
|
||||
|> put_flash(:error, "Error while processing OpenID request.")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
|
||||
defp do_sign_in(conn, user, auth) do
|
||||
conn
|
||||
|> Authentication.sign_in(user, auth)
|
||||
|> configure_session(renew: true)
|
||||
|> put_session(:live_socket_id, "users_socket:#{user.id}")
|
||||
|> redirect(to: root_path_for_user(user))
|
||||
end
|
||||
end
|
||||
@@ -1,7 +0,0 @@
|
||||
defmodule Web.BrowserController do
|
||||
use Web, :controller
|
||||
|
||||
def config(conn, _params) do
|
||||
render(conn, "browserconfig.xml")
|
||||
end
|
||||
end
|
||||
@@ -1,35 +0,0 @@
|
||||
defmodule Web.DebugController do
|
||||
@moduledoc """
|
||||
Dev only:
|
||||
|
||||
/dev/session
|
||||
/dev/samly
|
||||
"""
|
||||
use Web, :controller
|
||||
|
||||
def samly(conn, _params) do
|
||||
resp = """
|
||||
Samly.Provider state:
|
||||
#{pretty(Application.get_env(:samly, Samly.Provider))}
|
||||
|
||||
Service Providers:
|
||||
#{pretty(Application.get_env(:samly, :service_providers))}
|
||||
|
||||
Identity Providers:
|
||||
#{pretty(Application.get_env(:samly, :identity_providers))}
|
||||
|
||||
Samly Session:
|
||||
#{pretty(Samly.get_active_assertion(conn))}
|
||||
"""
|
||||
|
||||
send_resp(conn, :ok, resp)
|
||||
end
|
||||
|
||||
def session(conn, _params) do
|
||||
send_resp(conn, :ok, pretty(get_session(conn)))
|
||||
end
|
||||
|
||||
defp pretty(stuff) do
|
||||
inspect(stuff, pretty: true)
|
||||
end
|
||||
end
|
||||
19
apps/web/lib/web/controllers/error_html.ex
Normal file
19
apps/web/lib/web/controllers/error_html.ex
Normal file
@@ -0,0 +1,19 @@
|
||||
defmodule Web.ErrorHTML do
|
||||
use Web, :html
|
||||
|
||||
# If you want to customize your error pages,
|
||||
# uncomment the embed_templates/1 call below
|
||||
# and add pages to the error directory:
|
||||
#
|
||||
# * lib/web_web/controllers/error_html/404.html.heex
|
||||
# * lib/web_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
|
||||
15
apps/web/lib/web/controllers/error_json.ex
Normal file
15
apps/web/lib/web/controllers/error_json.ex
Normal file
@@ -0,0 +1,15 @@
|
||||
defmodule Web.ErrorJSON do
|
||||
# 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
|
||||
@@ -1,33 +0,0 @@
|
||||
defmodule Web.JSON.ConfigurationController do
|
||||
@moduledoc api_doc: [title: "Configurations", group: "Configuration"]
|
||||
@moduledoc """
|
||||
This endpoint allows an administrator to manage Configurations.
|
||||
|
||||
Updates here can be applied at runtime with little to no downtime of affected services.
|
||||
"""
|
||||
use Web, :controller
|
||||
alias Domain.Config
|
||||
alias Web.Auth.JSON.Authentication
|
||||
|
||||
action_fallback(Web.JSON.FallbackController)
|
||||
|
||||
@doc api_doc: [summary: "Get Configuration"]
|
||||
def show(conn, _params) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, configuration} <- Config.fetch_db_config(subject) do
|
||||
render(conn, "show.json", configuration: configuration)
|
||||
end
|
||||
end
|
||||
|
||||
@doc api_doc: [summary: "Update Configuration"]
|
||||
def update(conn, %{"configuration" => params}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
configuration = Config.fetch_db_config!()
|
||||
|
||||
with {:ok, %Config.Configuration{} = configuration} <-
|
||||
Config.update_config(configuration, params, subject) do
|
||||
render(conn, "show.json", configuration: configuration)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,67 +0,0 @@
|
||||
defmodule Web.JSON.DeviceController do
|
||||
@moduledoc api_doc: [title: "Devices", group: "Devices"]
|
||||
@moduledoc """
|
||||
This endpoint allows an administrator to manage Devices.
|
||||
"""
|
||||
use Web, :controller
|
||||
alias Domain.{Users, Devices}
|
||||
alias Web.Auth.JSON.Authentication
|
||||
|
||||
action_fallback(Web.JSON.FallbackController)
|
||||
|
||||
@doc api_doc: [summary: "List all Devices"]
|
||||
def index(conn, _attrs) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, devices} <- Devices.list_devices(subject) do
|
||||
defaults = Devices.defaults()
|
||||
render(conn, "index.json", devices: devices, defaults: defaults)
|
||||
end
|
||||
end
|
||||
|
||||
@doc api_doc: [summary: "Create a Device"]
|
||||
def create(conn, %{"device" => attrs}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, user} <- Users.fetch_user_by_id(attrs["user_id"], subject),
|
||||
{:ok, device} <- Devices.create_device_for_user(user, attrs, subject) do
|
||||
defaults = Devices.defaults()
|
||||
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> put_resp_header("location", ~p"/v0/devices/#{device}")
|
||||
|> render("show.json", device: device, defaults: defaults)
|
||||
end
|
||||
end
|
||||
|
||||
@doc api_doc: [summary: "Get Device by ID"]
|
||||
def show(conn, %{"id" => id}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, device} <- Devices.fetch_device_by_id(id, subject) do
|
||||
defaults = Devices.defaults()
|
||||
render(conn, "show.json", device: device, defaults: defaults)
|
||||
end
|
||||
end
|
||||
|
||||
@doc api_doc: [summary: "Update a Device"]
|
||||
def update(conn, %{"id" => id, "device" => attrs}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, device} <- Devices.fetch_device_by_id(id, subject),
|
||||
{:ok, device} <- Devices.update_device(device, attrs, subject) do
|
||||
defaults = Devices.defaults()
|
||||
render(conn, "show.json", device: device, defaults: defaults)
|
||||
end
|
||||
end
|
||||
|
||||
@doc api_doc: [summary: "Delete a Device"]
|
||||
def delete(conn, %{"id" => id}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, device} <- Devices.fetch_device_by_id(id, subject),
|
||||
{:ok, _device} <- Devices.delete_device(device, subject) do
|
||||
send_resp(conn, :no_content, "")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,44 +0,0 @@
|
||||
defmodule Web.JSON.FallbackController do
|
||||
@moduledoc """
|
||||
Translates controller action results into valid `Plug.Conn` responses.
|
||||
|
||||
See `Phoenix.Controller.action_fallback/1` for more details.
|
||||
"""
|
||||
use Web, :controller
|
||||
|
||||
# This clause is an example of how to handle resources that cannot be found.
|
||||
def call(conn, {:error, :not_found}) do
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> put_view(Web.ErrorView)
|
||||
|> render("404.json")
|
||||
end
|
||||
|
||||
def call(conn, {:error, :unauthorized}) do
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> put_view(Web.ErrorView)
|
||||
|> render("401.json")
|
||||
end
|
||||
|
||||
def call(conn, {:error, {:unauthorized, _context}}) do
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> put_view(Web.ErrorView)
|
||||
|> render("401.json")
|
||||
end
|
||||
|
||||
def call(conn, {:error, :internal_server_error}) do
|
||||
conn
|
||||
|> put_status(:internal_server_error)
|
||||
|> put_view(Web.ErrorView)
|
||||
|> render("500.json")
|
||||
end
|
||||
|
||||
def call(conn, {:error, %Ecto.Changeset{valid?: false} = changeset}) do
|
||||
conn
|
||||
|> put_status(422)
|
||||
|> put_view(Web.JSON.ChangesetView)
|
||||
|> render("error.json", changeset: changeset)
|
||||
end
|
||||
end
|
||||
@@ -1,61 +0,0 @@
|
||||
defmodule Web.JSON.RuleController do
|
||||
@moduledoc api_doc: [title: "Rules", group: "Rules"]
|
||||
@moduledoc """
|
||||
This endpoint allows an adminisrator to manage Rules.
|
||||
"""
|
||||
use Web, :controller
|
||||
alias Domain.Rules
|
||||
alias Web.Auth.JSON.Authentication
|
||||
|
||||
action_fallback(Web.JSON.FallbackController)
|
||||
|
||||
@doc api_doc: [summary: "List all Rules"]
|
||||
def index(conn, _params) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, rules} <- Rules.list_rules(subject) do
|
||||
render(conn, "index.json", rules: rules)
|
||||
end
|
||||
end
|
||||
|
||||
@doc api_doc: [summary: "Create a Rule"]
|
||||
def create(conn, %{"rule" => attrs}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, rule} <- Rules.create_rule(attrs, subject) do
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> put_resp_header("location", ~p"/v0/rules/#{rule}")
|
||||
|> render("show.json", rule: rule)
|
||||
end
|
||||
end
|
||||
|
||||
@doc api_doc: [summary: "Get Rule by ID"]
|
||||
def show(conn, %{"id" => id}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, rule} <- Rules.fetch_rule_by_id(id, subject) do
|
||||
render(conn, "show.json", rule: rule)
|
||||
end
|
||||
end
|
||||
|
||||
@doc api_doc: [summary: "Update a Rule"]
|
||||
def update(conn, %{"id" => id, "rule" => attrs}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, rule} <- Rules.fetch_rule_by_id(id, subject),
|
||||
{:ok, rule} <- Rules.update_rule(rule, attrs, subject) do
|
||||
render(conn, "show.json", rule: rule)
|
||||
end
|
||||
end
|
||||
|
||||
@doc api_doc: [summary: "Delete a Rule"]
|
||||
def delete(conn, %{"id" => id}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, rule} <- Rules.fetch_rule_by_id(id, subject),
|
||||
{:ok, _rule} <- Rules.delete_rule(rule, subject) do
|
||||
send_resp(conn, :no_content, "")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,116 +0,0 @@
|
||||
defmodule Web.JSON.UserController do
|
||||
@moduledoc api_doc: [title: "Users", sidebar_position: 2, toc_max_heading_level: 4]
|
||||
@moduledoc """
|
||||
This endpoint allows an administrator to manage Users.
|
||||
|
||||
## Auto-Create Users from OpenID or SAML providers
|
||||
|
||||
You can set Configuration option `auto_create_users` to `true` to automatically create users
|
||||
from OpenID or SAML providers. Use it with care as anyone with access to the provider will be
|
||||
able to log-in to Firezone.
|
||||
|
||||
If `auto_create_users` is `false`, then you need to provision users with `password` attribute,
|
||||
otherwise they will have no means to log in.
|
||||
|
||||
## Disabling users
|
||||
|
||||
Even though API returns `disabled_at` attribute, currently, it's not possible to disable users via API,
|
||||
since this field is only for internal use by automatic user disabling mechanism on OIDC/SAML errors.
|
||||
"""
|
||||
use Web, :controller
|
||||
alias Domain.Users
|
||||
alias Web.Auth.JSON.Authentication
|
||||
|
||||
action_fallback(Web.JSON.FallbackController)
|
||||
|
||||
@doc api_doc: [action: "List all Users"]
|
||||
def index(conn, _attrs) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, users} <- Users.list_users(subject) do
|
||||
render(conn, "index.json", users: users)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Create a new User.
|
||||
|
||||
This endpoint is useful in two cases:
|
||||
|
||||
1. When [Local Authentication](/docs/authenticate/local-auth/) is enabled (discouraged in
|
||||
production deployments), it allows an administrator to provision users with their passwords;
|
||||
2. When `auto_create_users` in the associated OpenID or SAML configuration is disabled,
|
||||
it allows an administrator to provision users with their emails beforehand, effectively
|
||||
whitelisting specific users for authentication.
|
||||
|
||||
If `auto_create_users` is `true` in the associated OpenID or SAML configuration, there is no need
|
||||
to provision users; they will be created automatically when they log in for the first time using
|
||||
the associated OpenID or SAML provider.
|
||||
|
||||
#### User Attributes
|
||||
|
||||
| Attribute | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `role` | `admin` or `unprivileged` (default) | No | User role. |
|
||||
| `email` | `string` | Yes | Email which will be used to identify the user. |
|
||||
| `password` | `string` | No | A password that can be used for login-password authentication. |
|
||||
| `password_confirmation` | `string` | -> | Is required when the `password` is set. |
|
||||
"""
|
||||
@doc api_doc: [action: "Create a User"]
|
||||
def create(conn, %{"user" => %{"role" => "admin"} = attrs}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, %Users.User{} = user} <- Users.create_user(:admin, attrs, subject) do
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> put_resp_header("location", ~p"/v0/users/#{user}")
|
||||
|> render("show.json", user: user)
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => attrs}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, %Users.User{} = user} <- Users.create_user(:unprivileged, attrs, subject) do
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> put_resp_header("location", ~p"/v0/users/#{user}")
|
||||
|> render("show.json", user: user)
|
||||
end
|
||||
end
|
||||
|
||||
@doc api_doc: [summary: "Get User by ID or Email"]
|
||||
def show(conn, %{"id" => id_or_email}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, %Users.User{} = user} <-
|
||||
Users.fetch_user_by_id_or_email(id_or_email, subject) do
|
||||
render(conn, "show.json", user: user)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
For details please see [Create a User](#create-a-user-post-v0users) section.
|
||||
"""
|
||||
@doc api_doc: [action: "Update a User"]
|
||||
def update(conn, %{"id" => id_or_email, "user" => attrs}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, %Users.User{} = user} <- Users.fetch_user_by_id_or_email(id_or_email, subject),
|
||||
{:ok, %Users.User{} = user} <- Users.update_user(user, attrs, subject) do
|
||||
render(conn, "show.json", user: user)
|
||||
end
|
||||
end
|
||||
|
||||
@doc api_doc: [summary: "Delete a User"]
|
||||
def delete(conn, %{"id" => id_or_email}) do
|
||||
subject = Authentication.get_current_subject(conn)
|
||||
|
||||
with {:ok, %Users.User{} = user} <- Users.fetch_user_by_id_or_email(id_or_email, subject),
|
||||
{:ok, %Users.User{}} <- Users.delete_user(user, subject) do
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(:no_content, "")
|
||||
end
|
||||
end
|
||||
end
|
||||
9
apps/web/lib/web/controllers/page_controller.ex
Normal file
9
apps/web/lib/web/controllers/page_controller.ex
Normal file
@@ -0,0 +1,9 @@
|
||||
defmodule Web.PageController do
|
||||
use Web, :controller
|
||||
|
||||
def home(conn, _params) do
|
||||
# The home page is often custom made,
|
||||
# so skip the default app layout.
|
||||
render(conn, :home, layout: false)
|
||||
end
|
||||
end
|
||||
5
apps/web/lib/web/controllers/page_html.ex
Normal file
5
apps/web/lib/web/controllers/page_html.ex
Normal file
@@ -0,0 +1,5 @@
|
||||
defmodule Web.PageHTML do
|
||||
use Web, :html
|
||||
|
||||
embed_templates "page_html/*"
|
||||
end
|
||||
237
apps/web/lib/web/controllers/page_html/home.html.heex
Normal file
237
apps/web/lib/web/controllers/page_html/home.html.heex
Normal file
@@ -0,0 +1,237 @@
|
||||
<.flash_group flash={@flash} />
|
||||
<div class="fixed inset-y-0 right-0 left-[40rem] hidden lg:block xl:left-[50rem] z-0">
|
||||
<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:py-28 sm:px-6 lg:px-8 xl:py-32 xl:px-28">
|
||||
<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="mt-10 flex items-center text-sm font-semibold leading-6 text-brand">
|
||||
Phoenix Framework
|
||||
<small class="ml-3 rounded-full bg-brand/5 px-2 text-[0.8125rem] font-medium leading-6">
|
||||
v1.7
|
||||
</small>
|
||||
</h1>
|
||||
<p class="mt-4 text-[2rem] font-semibold leading-10 tracking-tighter text-zinc-900">
|
||||
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 & 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/v1.7/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://elixir-slackin.herokuapp.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="M3.95 9.85a1.47 1.47 0 1 1-2.94 0 1.47 1.47 0 0 1 1.47-1.472h1.47v1.471Zm.735 0a1.47 1.47 0 1 1 2.94 0v3.678a1.47 1.47 0 1 1-2.94 0V9.85ZM6.156 3.942a1.47 1.47 0 0 1-1.47-1.472 1.47 1.47 0 1 1 2.94 0v1.472h-1.47Zm0 .747c.813 0 1.47.658 1.47 1.471a1.47 1.47 0 0 1-1.47 1.472H2.47A1.47 1.47 0 0 1 1 6.16 1.47 1.47 0 0 1 2.47 4.69h3.686ZM12.048 6.16a1.47 1.47 0 1 1 2.94 0 1.47 1.47 0 0 1-1.47 1.472h-1.47V6.16Zm-.735 0a1.47 1.47 0 1 1-2.94 0V2.47a1.47 1.47 0 1 1 2.94 0v3.69ZM9.843 12.057c.813 0 1.47.657 1.47 1.471a1.47 1.47 0 1 1-2.94 0v-1.471h1.47Zm0-.736a1.47 1.47 0 0 1-1.47-1.472 1.47 1.47 0 0 1 1.47-1.471h3.686c.813 0 1.47.658 1.47 1.471a1.47 1.47 0 0 1-1.47 1.472H9.843Z" />
|
||||
</svg>
|
||||
Join our Slack channel
|
||||
</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,27 +0,0 @@
|
||||
defmodule Web.RootController do
|
||||
@moduledoc """
|
||||
Firezone landing page -- show auth methods.
|
||||
"""
|
||||
use Web, :controller
|
||||
|
||||
def index(conn, _params) do
|
||||
%{
|
||||
local_auth_enabled: {_, local_auth_enabled},
|
||||
openid_connect_providers: {_, openid_connect_providers},
|
||||
saml_identity_providers: {_, saml_identity_providers}
|
||||
} =
|
||||
Domain.Config.fetch_source_and_configs!([
|
||||
:local_auth_enabled,
|
||||
:openid_connect_providers,
|
||||
:saml_identity_providers
|
||||
])
|
||||
|
||||
conn
|
||||
|> render(
|
||||
"auth.html",
|
||||
local_enabled: local_auth_enabled,
|
||||
openid_connect_providers: openid_connect_providers,
|
||||
saml_identity_providers: saml_identity_providers
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,41 +0,0 @@
|
||||
defmodule Web.UserController do
|
||||
@moduledoc """
|
||||
Implements synchronous User requests.
|
||||
"""
|
||||
use Web, :controller
|
||||
alias Domain.Users
|
||||
alias Web.Auth.HTML.Authentication
|
||||
require Logger
|
||||
|
||||
def delete(conn, _params) do
|
||||
%{actor: {:user, user}} = subject = Authentication.get_current_subject(conn)
|
||||
|
||||
case Users.delete_user(user, subject) do
|
||||
{:ok, _user} ->
|
||||
Web.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})
|
||||
Authentication.sign_out(conn)
|
||||
|
||||
{:error, :cant_delete_the_last_admin} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> put_view(Web.ErrorView)
|
||||
|> render("422.json", reason: "Can't delete the last admin user.")
|
||||
|
||||
{:error, %Ecto.Changeset{errors: [id: {"is stale", _}]}} ->
|
||||
not_found(conn)
|
||||
|
||||
{:error, {:unauthorized, _context}} ->
|
||||
not_found(conn)
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
not_found(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp not_found(conn) do
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> put_view(Web.ErrorView)
|
||||
|> render("404.json")
|
||||
end
|
||||
end
|
||||
@@ -1,22 +1,5 @@
|
||||
defmodule Web.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :web
|
||||
alias Web.ProxyHeaders
|
||||
alias Web.HeaderHelpers
|
||||
alias Web.Session
|
||||
|
||||
plug Web.Plug.PathPrefix
|
||||
|
||||
if Application.compile_env(:web, :sql_sandbox) do
|
||||
plug Phoenix.Ecto.SQL.Sandbox
|
||||
end
|
||||
|
||||
socket "/socket", Web.UserSocket,
|
||||
websocket: [
|
||||
connect_info: [:user_agent, :peer_data, :x_headers, :uri],
|
||||
# XXX: channel token should prevent CSWH but double check
|
||||
check_origin: false
|
||||
],
|
||||
longpoll: false
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket,
|
||||
websocket: [
|
||||
@@ -25,10 +8,8 @@ defmodule Web.Endpoint do
|
||||
:peer_data,
|
||||
:x_headers,
|
||||
:uri,
|
||||
session: {Session, :options, []}
|
||||
],
|
||||
# XXX: csrf token should prevent CSWH but double check
|
||||
check_origin: false
|
||||
session: {Web.Session, :options, []}
|
||||
]
|
||||
],
|
||||
longpoll: false
|
||||
|
||||
@@ -45,20 +26,18 @@ defmodule Web.Endpoint do
|
||||
# Code reloading can be explicitly enabled under the
|
||||
# :code_reloader configuration of your endpoint.
|
||||
if code_reloading? do
|
||||
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket,
|
||||
websocket: [
|
||||
connect_info: [
|
||||
session: {Session, :options, []}
|
||||
],
|
||||
# XXX: csrf token should prevent CSWH but double check
|
||||
check_origin: false
|
||||
]
|
||||
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||
|
||||
plug Phoenix.LiveReloader
|
||||
plug Phoenix.CodeReloader
|
||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :domain
|
||||
end
|
||||
|
||||
plug RemoteIp,
|
||||
headers: ["x-forwarded-for"],
|
||||
proxies: {__MODULE__, :external_trusted_proxies, []},
|
||||
clients: {__MODULE__, :clients, []}
|
||||
|
||||
plug Plug.RequestId
|
||||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||
|
||||
@@ -69,15 +48,19 @@ defmodule Web.Endpoint do
|
||||
|
||||
plug Plug.MethodOverride
|
||||
plug Plug.Head
|
||||
plug(:session)
|
||||
|
||||
if HeaderHelpers.proxied?() do
|
||||
plug ProxyHeaders
|
||||
end
|
||||
# TODO: ensure that phoenix configured to resolve opts at runtime
|
||||
plug Plug.Session, Web.Session.options()
|
||||
|
||||
plug Web.Router
|
||||
|
||||
defp session(conn, _opts) do
|
||||
Plug.Session.call(conn, Plug.Session.init(Session.options()))
|
||||
def external_trusted_proxies do
|
||||
Domain.Config.fetch_env!(:web, :external_trusted_proxies)
|
||||
|> Enum.map(&to_string/1)
|
||||
end
|
||||
|
||||
def clients do
|
||||
Domain.Config.fetch_env!(:web, :private_clients)
|
||||
|> Enum.map(&to_string/1)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
defmodule Web.ErrorHelpers do
|
||||
@moduledoc """
|
||||
Conveniences for translating and building error messages.
|
||||
"""
|
||||
use Phoenix.HTML
|
||||
import Ecto.Changeset, only: [traverse_errors: 2]
|
||||
|
||||
def aggregated_errors(%Ecto.Changeset{} = changeset) do
|
||||
traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn
|
||||
{key, {:array, value}}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
|
||||
{key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end)
|
||||
|> Enum.reduce("", fn {key, value}, acc ->
|
||||
joined_errors = Enum.join(value, "; ")
|
||||
"#{acc}#{key}: #{joined_errors}\n"
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates tag for inlined form input errors.
|
||||
"""
|
||||
def error_tag(form, field) do
|
||||
values = Keyword.get_values(form.errors, field)
|
||||
|
||||
values
|
||||
|> Enum.map(fn error ->
|
||||
content_tag(:span, translate_error(error),
|
||||
class: "help-block"
|
||||
# XXX: data: [phx_error_for: input_id(form, field)]
|
||||
)
|
||||
end)
|
||||
|> Enum.intersperse(", ")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds "is-danger" to input elements that have errors
|
||||
"""
|
||||
def input_error_class(form, field) do
|
||||
case Keyword.get_values(form.errors, field) do
|
||||
[] ->
|
||||
""
|
||||
|
||||
_ ->
|
||||
"is-danger"
|
||||
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(Web.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(Web.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,24 +1,3 @@
|
||||
defmodule Web.Gettext do
|
||||
@moduledoc """
|
||||
A module providing Internationalization with a gettext-based API.
|
||||
|
||||
By using [Gettext](https://hexdocs.pm/gettext),
|
||||
your module gains a set of macros for translations, for example:
|
||||
|
||||
import Web.Gettext
|
||||
|
||||
# Simple translation
|
||||
gettext("Here is the string to translate")
|
||||
|
||||
# Plural translation
|
||||
ngettext("Here is the string to translate",
|
||||
"Here are the strings to translate",
|
||||
3)
|
||||
|
||||
# Domain-based translation
|
||||
dgettext("errors", "Here is the error message to translate")
|
||||
|
||||
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||
"""
|
||||
use Gettext, otp_app: :web
|
||||
end
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
defmodule Web.HeaderHelpers do
|
||||
@moduledoc """
|
||||
Helper functionalities with regards to headers
|
||||
"""
|
||||
|
||||
@remote_ip_headers ["x-forwarded-for"]
|
||||
|
||||
def external_trusted_proxies do
|
||||
Domain.Config.fetch_env!(:web, :external_trusted_proxies)
|
||||
|> Enum.map(&to_string/1)
|
||||
end
|
||||
|
||||
def clients do
|
||||
Domain.Config.fetch_env!(:web, :private_clients)
|
||||
|> Enum.map(&to_string/1)
|
||||
end
|
||||
|
||||
def proxied?, do: external_trusted_proxies() != []
|
||||
|
||||
def remote_ip_opts do
|
||||
[
|
||||
headers: @remote_ip_headers,
|
||||
proxies: external_trusted_proxies(),
|
||||
clients: clients()
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -1,42 +0,0 @@
|
||||
<%= render(Web.SharedView, "heading.html",
|
||||
page_subtitle: @page_subtitle,
|
||||
page_title: @page_title
|
||||
) %>
|
||||
|
||||
<section class="section is-main-section">
|
||||
<%= render(Web.SharedView, "flash.html", assigns) %>
|
||||
|
||||
<div class="block">
|
||||
<table class="table is-bordered is-hoverable is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Checked At</th>
|
||||
<th>Resolved IP</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for connectivity_check <- @connectivity_checks do %>
|
||||
<tr>
|
||||
<td
|
||||
id={"connectivity_check-#{connectivity_check.id}"}
|
||||
phx-hook="FormatTimestamp"
|
||||
data-timestamp={connectivity_check.inserted_at}
|
||||
>
|
||||
…
|
||||
</td>
|
||||
<td><%= connectivity_check.response_body %></td>
|
||||
<td>
|
||||
<span
|
||||
data-tooltip={"HTTP Response Code: #{connectivity_check.response_code}"}
|
||||
class={connectivity_check_span_class(connectivity_check.response_code)}
|
||||
>
|
||||
<i class={connectivity_check_icon_class(connectivity_check.response_code)}></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,29 +0,0 @@
|
||||
defmodule Web.ConnectivityCheckLive.Index do
|
||||
@moduledoc """
|
||||
Manages the connectivity_checks view.
|
||||
"""
|
||||
use Web, :live_view
|
||||
|
||||
alias Domain.ConnectivityChecks
|
||||
|
||||
@page_title "WAN Connectivity Checks"
|
||||
@page_subtitle """
|
||||
Firezone periodically checks for WAN connectivity to the Internet and logs the result here. \
|
||||
This is used to determine the public IP address of this server for populating the default \
|
||||
endpoint field in device configurations.
|
||||
"""
|
||||
|
||||
@impl Phoenix.LiveView
|
||||
def mount(_params, _session, socket) do
|
||||
connectivity_checks =
|
||||
ConnectivityChecks.list_connectivity_checks(socket.assigns.subject, limit: 20)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:connectivity_checks, connectivity_checks)
|
||||
|> assign(:page_subtitle, @page_subtitle)
|
||||
|> assign(:page_title, @page_title)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
@@ -1,22 +0,0 @@
|
||||
<%= render(Web.SharedView, "heading.html",
|
||||
page_subtitle: @page_subtitle,
|
||||
page_title: @page_title
|
||||
) %>
|
||||
|
||||
<section class="section is-main-section">
|
||||
<%= render(Web.SharedView, "flash.html", assigns) %>
|
||||
|
||||
<div class="block is-horizontally-scrollable">
|
||||
<%= render(Web.SharedView, "devices_table.html",
|
||||
devices: @devices,
|
||||
show_user: true,
|
||||
socket: @socket
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Devices can be added when viewing a User. <%= live_redirect("Go to users ->",
|
||||
to: ~p"/users"
|
||||
) %>
|
||||
</p>
|
||||
</section>
|
||||
@@ -1,38 +0,0 @@
|
||||
defmodule Web.DeviceLive.Admin.Index do
|
||||
@moduledoc """
|
||||
Handles Device LiveViews.
|
||||
"""
|
||||
use Web, :live_view
|
||||
alias Domain.{Devices, Repo}
|
||||
|
||||
@page_title "All Devices"
|
||||
@page_subtitle """
|
||||
Each device corresponds to a WireGuard configuration for connecting to this Firezone server.
|
||||
"""
|
||||
|
||||
@impl Phoenix.LiveView
|
||||
def mount(_params, _session, socket) do
|
||||
with {:ok, devices} <- Devices.list_devices(socket.assigns.subject) do
|
||||
devices =
|
||||
devices
|
||||
|> Repo.preload(:user)
|
||||
|> Enum.sort_by(& &1.user_id)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:devices, devices)
|
||||
|> assign(:page_subtitle, @page_subtitle)
|
||||
|> assign(:page_title, @page_title)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Needed because this view will receive handle_params when modal is closed.
|
||||
"""
|
||||
@impl Phoenix.LiveView
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
<%= render(Web.SharedView, "heading.html", page_title: "Devices |> #{@page_title}") %>
|
||||
<%= render(Web.SharedView, "show_device.html", assigns) %>
|
||||
@@ -1,59 +0,0 @@
|
||||
defmodule Web.DeviceLive.Admin.Show do
|
||||
@moduledoc """
|
||||
Shows a device for an admin user.
|
||||
"""
|
||||
use Web, :live_view
|
||||
alias Domain.{Devices, Users}
|
||||
|
||||
@impl Phoenix.LiveView
|
||||
def mount(%{"id" => device_id} = _params, _session, socket) do
|
||||
with {:ok, device} <- Devices.fetch_device_by_id(device_id, socket.assigns.subject) do
|
||||
{:ok, assign(socket, assigns(device))}
|
||||
else
|
||||
{:error, {:unauthorized, _context}} ->
|
||||
{:ok, not_authorized(socket)}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:ok, not_authorized(socket)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Needed because this view will receive handle_params when modal is closed.
|
||||
"""
|
||||
@impl Phoenix.LiveView
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveView
|
||||
def handle_event("delete_device", _params, socket) do
|
||||
case Devices.delete_device(socket.assigns.device, socket.assigns.subject) do
|
||||
{:ok, _deleted_device} ->
|
||||
{:noreply, redirect(socket, to: ~p"/devices")}
|
||||
|
||||
{:error, {:unauthorized, _context}} ->
|
||||
{:noreply, not_authorized(socket)}
|
||||
|
||||
{:error, msg} ->
|
||||
{:noreply, put_flash(socket, :error, "Error deleting device: #{msg}")}
|
||||
end
|
||||
end
|
||||
|
||||
defp assigns(device) do
|
||||
defaults = Devices.defaults()
|
||||
|
||||
[
|
||||
device: device,
|
||||
user: Users.fetch_user_by_id!(device.user_id),
|
||||
page_title: device.name,
|
||||
allowed_ips: Devices.get_allowed_ips(device, defaults),
|
||||
dns: Devices.get_dns(device, defaults),
|
||||
endpoint: Devices.get_endpoint(device, defaults),
|
||||
port: Domain.Config.fetch_env!(:domain, :wireguard_port),
|
||||
mtu: Devices.get_mtu(device, defaults),
|
||||
persistent_keepalive: Devices.get_persistent_keepalive(device, defaults),
|
||||
config: Web.WireguardConfigView.render("device.conf", %{device: device})
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -1,113 +0,0 @@
|
||||
defmodule Web.DeviceLive.NewFormComponent do
|
||||
@moduledoc """
|
||||
Handles device form.
|
||||
"""
|
||||
use Web, :live_component
|
||||
alias Domain.Devices
|
||||
alias Web.ErrorHelpers
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def mount(socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:device, nil)
|
||||
|> assign(:config, nil)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def update(assigns, socket) do
|
||||
changeset = Devices.new_device()
|
||||
|
||||
config =
|
||||
Domain.Config.fetch_source_and_configs!(~w(
|
||||
default_client_mtu
|
||||
default_client_endpoint
|
||||
default_client_persistent_keepalive
|
||||
default_client_dns
|
||||
default_client_allowed_ips
|
||||
)a)
|
||||
|> Enum.into(%{}, fn {k, {_s, v}} -> {k, v} end)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(config)
|
||||
|> assign_new(:changeset, fn -> changeset end)
|
||||
|> assign(use_default_fields(changeset))
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def handle_event("change", %{"device" => device_params}, socket) do
|
||||
attrs =
|
||||
device_params
|
||||
|> Map.update("dns", nil, &binary_to_list/1)
|
||||
|> Map.update("allowed_ips", nil, &binary_to_list/1)
|
||||
|
||||
# Note: change_device is used here because when you type in at some point
|
||||
# the input can be empty while you typing, which will immediately put back
|
||||
# an new default value from the changeset.
|
||||
changeset = Devices.change_device(%Devices.Device{}, attrs)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:changeset, changeset)
|
||||
|> assign(use_default_fields(changeset))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def handle_event("save", %{"device" => device_params}, socket) do
|
||||
device_params
|
||||
|> Map.update("dns", nil, &binary_to_list/1)
|
||||
|> Map.update("allowed_ips", nil, &binary_to_list/1)
|
||||
|> create_device(socket)
|
||||
|> case do
|
||||
{:ok, device} ->
|
||||
send_update(Web.ModalComponent, id: :modal, hide_footer_content: true)
|
||||
|
||||
device_config = Web.WireguardConfigView.render("base64_device.conf", %{device: device})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:device, device)
|
||||
|> assign(:config, device_config)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, {:unauthorized, _context}} ->
|
||||
{:noreply, not_authorized(socket)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, ErrorHelpers.aggregated_errors(changeset))
|
||||
|> assign(:changeset, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp use_default_fields(changeset) do
|
||||
~w(
|
||||
use_default_allowed_ips
|
||||
use_default_dns
|
||||
use_default_endpoint
|
||||
use_default_mtu
|
||||
use_default_persistent_keepalive
|
||||
)a
|
||||
|> Map.new(&{&1, Ecto.Changeset.get_field(changeset, &1)})
|
||||
end
|
||||
|
||||
defp create_device(attrs, socket) do
|
||||
Devices.create_device_for_user(socket.assigns.user, attrs, socket.assigns.subject)
|
||||
end
|
||||
|
||||
defp binary_to_list(binary) when is_binary(binary),
|
||||
do: binary |> String.trim() |> String.split(",")
|
||||
|
||||
defp binary_to_list(list) when is_list(list),
|
||||
do: list
|
||||
end
|
||||
@@ -1,291 +0,0 @@
|
||||
<div
|
||||
id="new-device-data"
|
||||
data-public-key={@device && @device.public_key}
|
||||
data-device-name={@device && @device.name}
|
||||
data-config={@config}
|
||||
phx-hook="RenderConfig"
|
||||
>
|
||||
<%= if @device && @config do %>
|
||||
<div class="content">
|
||||
<p>
|
||||
<strong>Device added!</strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To use this configuration, you'll need a WireGuard client installed
|
||||
for your device. See
|
||||
<a target="_blank" href="https://docs.firezone.dev/user-guides/client-instructions/">
|
||||
the Firezone documentation
|
||||
</a>
|
||||
for a step-by-step guide.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>NOTE:</strong>
|
||||
This configuration <strong>WILL NOT</strong>
|
||||
be viewable again. Please ensure you've downloaded the
|
||||
configuration file or copied it somewhere safe
|
||||
before closing this window.
|
||||
</p>
|
||||
|
||||
<div id="generating-config">
|
||||
<p>Rendering configuration...</p>
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<a class="button is-hidden" id="download-config">
|
||||
Download WireGuard Configuration
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<canvas id="qr-canvas">
|
||||
Generating QR code...
|
||||
</canvas>
|
||||
</p>
|
||||
<p>
|
||||
<pre id="wg-conf-container" class="is-hidden"><code id="wg-conf" class="language-toml"></code></pre>
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div>
|
||||
<.form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
id="create-device"
|
||||
phx-change="change"
|
||||
phx-target={@myself}
|
||||
phx-submit="save"
|
||||
>
|
||||
<%= hidden_input(f, :public_key, id: "device-public-key", phx_hook: "GenerateKeyPair") %>
|
||||
<%= hidden_input(f, :preshared_key) %>
|
||||
|
||||
<%= if @changeset.action do %>
|
||||
<div class="notification is-danger">
|
||||
<div class="flash-error">
|
||||
<%= error_tag(f, :base) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :name, class: "label") %>
|
||||
<div class="control">
|
||||
<%= text_input(f, :name, class: "input #{input_error_class(f, :name)}") %>
|
||||
</div>
|
||||
<p class="help is-danger">
|
||||
<%= error_tag(f, :name) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :description, class: "label") %>
|
||||
<div class="control">
|
||||
<%= textarea(
|
||||
f,
|
||||
:description,
|
||||
placeholder: "Enter an optional description for this device",
|
||||
class: "pre-wrapped input #{input_error_class(f, :description)}"
|
||||
) %>
|
||||
</div>
|
||||
<p class="help is-danger">
|
||||
<%= error_tag(f, :description) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= if Domain.Devices.authorize_device_configuration(@subject) == :ok do %>
|
||||
<div class="field">
|
||||
<%= label(f, :use_default_allowed_ips, "Use Default Allowed IPs", class: "label") %>
|
||||
<div class="control">
|
||||
<label class="radio">
|
||||
<%= radio_button(f, :use_default_allowed_ips, true) %> Yes
|
||||
</label>
|
||||
<label class="radio">
|
||||
<%= radio_button(f, :use_default_allowed_ips, false) %> No
|
||||
</label>
|
||||
</div>
|
||||
<p class="help">
|
||||
Default: <%= Enum.join(@default_client_allowed_ips, ", ") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :allowed_ips, "Allowed IPs", class: "label") %>
|
||||
<div class="control">
|
||||
<%= textarea(f, :allowed_ips,
|
||||
class: "textarea #{input_error_class(f, :allowed_ips)}",
|
||||
disabled: @use_default_allowed_ips,
|
||||
value: list_value(f, :allowed_ips)
|
||||
) %>
|
||||
</div>
|
||||
<p class="help is-danger">
|
||||
<%= error_tag(f, :allowed_ips) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :use_default_dns, "Use Default DNS Servers", class: "label") %>
|
||||
<div class="control">
|
||||
<label class="radio">
|
||||
<%= radio_button(f, :use_default_dns, true) %> Yes
|
||||
</label>
|
||||
<label class="radio">
|
||||
<%= radio_button(f, :use_default_dns, false) %> No
|
||||
</label>
|
||||
</div>
|
||||
<p class="help">
|
||||
Default: <%= Enum.join(@default_client_dns, ", ") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :dns, "DNS Servers", class: "label") %>
|
||||
<div class="control">
|
||||
<%= text_input(f, :dns,
|
||||
class: "input #{input_error_class(f, :dns)}",
|
||||
disabled: @use_default_dns,
|
||||
value: list_value(f, :dns)
|
||||
) %>
|
||||
</div>
|
||||
<p class="help is-danger">
|
||||
<%= error_tag(f, :dns) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :use_default_endpoint, "Use Default Endpoint", class: "label") %>
|
||||
<div class="control">
|
||||
<label class="radio">
|
||||
<%= radio_button(f, :use_default_endpoint, true) %> Yes
|
||||
</label>
|
||||
<label class="radio">
|
||||
<%= radio_button(f, :use_default_endpoint, false) %> No
|
||||
</label>
|
||||
</div>
|
||||
<p class="help">
|
||||
Default: <%= @default_client_endpoint %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :endpoint, "Server Endpoint", class: "label") %>
|
||||
<p>The IP of the server this device should connect to.</p>
|
||||
<div class="control">
|
||||
<%= text_input(f, :endpoint,
|
||||
class: "input #{input_error_class(f, :endpoint)}",
|
||||
disabled: @use_default_endpoint
|
||||
) %>
|
||||
</div>
|
||||
<p class="help is-danger">
|
||||
<%= error_tag(f, :endpoint) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :use_default_mtu, "Use Default MTU", class: "label") %>
|
||||
<div class="control">
|
||||
<label class="radio">
|
||||
<%= radio_button(f, :use_default_mtu, true) %> Yes
|
||||
</label>
|
||||
<label class="radio">
|
||||
<%= radio_button(f, :use_default_mtu, false) %> No
|
||||
</label>
|
||||
</div>
|
||||
<p class="help">
|
||||
Default: <%= @default_client_mtu %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :mtu, "Interface MTU", class: "label") %>
|
||||
<p>The WireGuard interface MTU for this Device.</p>
|
||||
<div class="control">
|
||||
<%= text_input(f, :mtu,
|
||||
class: "input #{input_error_class(f, :mtu)}",
|
||||
disabled: @use_default_mtu
|
||||
) %>
|
||||
</div>
|
||||
<p class="help is-danger">
|
||||
<%= error_tag(f, :mtu) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :use_default_persistent_keepalive, "Use Default Persistent Keepalive",
|
||||
class: "label"
|
||||
) %>
|
||||
<div class="control">
|
||||
<label class="radio">
|
||||
<%= radio_button(f, :use_default_persistent_keepalive, true) %> Yes
|
||||
</label>
|
||||
<label class="radio">
|
||||
<%= radio_button(f, :use_default_persistent_keepalive, false) %> No
|
||||
</label>
|
||||
</div>
|
||||
<p class="help">
|
||||
Default: <%= @default_client_persistent_keepalive %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :persistent_keepalive, "Persistent Keepalive", class: "label") %>
|
||||
<p>
|
||||
Interval for WireGuard <a href="https://www.wireguard.com/quickstart/#nat-and-firewall-traversal-persistence">
|
||||
persistent keepalive</a>. A value of 0 disables this. Leave this disabled
|
||||
unless you're experiencing NAT or firewall traversal problems.
|
||||
</p>
|
||||
<div class="control">
|
||||
<%= text_input(f, :persistent_keepalive,
|
||||
class: "input #{input_error_class(f, :persistent_keepalive)}",
|
||||
disabled: @use_default_persistent_keepalive
|
||||
) %>
|
||||
</div>
|
||||
<p class="help is-danger">
|
||||
<%= error_tag(f, :persistent_keepalive) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :ipv4, "Tunnel IPv4 Address", class: "label") %>
|
||||
|
||||
<div class="control">
|
||||
<%= text_input(
|
||||
f,
|
||||
:ipv4,
|
||||
placeholder: "Leave blank to let Firezone assign an IPv4 address",
|
||||
class: "input #{input_error_class(f, :ipv4)}"
|
||||
) %>
|
||||
</div>
|
||||
<p class="help is-danger">
|
||||
<%= error_tag(f, :ipv4) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :ipv6, "Tunnel IPv6 Address", class: "label") %>
|
||||
|
||||
<div class="control">
|
||||
<%= text_input(
|
||||
f,
|
||||
:ipv6,
|
||||
placeholder: "Leave blank to let Firezone assign an IPv6 address",
|
||||
class: "input #{input_error_class(f, :ipv6)}"
|
||||
) %>
|
||||
</div>
|
||||
<p class="help is-danger">
|
||||
<%= error_tag(f, :ipv6) %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @changeset.action do %>
|
||||
<div class="notification is-danger">
|
||||
<div class="flash-error">
|
||||
Error creating device. Scroll up to view fields with errors.
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,134 +0,0 @@
|
||||
<%= if @live_action == :new do %>
|
||||
<%= live_modal(
|
||||
Web.DeviceLive.NewFormComponent,
|
||||
return_to: ~p"/user_devices",
|
||||
title: "Add Device",
|
||||
current_user: @current_user,
|
||||
user: @current_user,
|
||||
id: "create-device-component",
|
||||
form: "create-device",
|
||||
button_text: "Generate Configuration",
|
||||
subject: @subject
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
<section class="section is-main-section">
|
||||
<%= render(Web.SharedView, "flash.html", assigns) %>
|
||||
<h4 class="title is-4"><%= @page_title %></h4>
|
||||
|
||||
<div class="block">
|
||||
<%= @page_subtitle %>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
You'll need a WireGuard client installed in order to connect to this Firezone
|
||||
instance. See
|
||||
<a target="_blank" href="https://docs.firezone.dev/user-guides/client-instructions/">
|
||||
the Firezone documentation
|
||||
</a>
|
||||
for a step-by-step guide.
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<%= unless Enum.empty?(@devices) do %>
|
||||
<table class="table is-bordered is-hoverable is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Tunnel IPv4</th>
|
||||
<th>Tunnel IPv6</th>
|
||||
<th>Public key</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for device <- @devices do %>
|
||||
<tr>
|
||||
<td>
|
||||
<.link navigate={~p"/user_devices/#{device}"}>
|
||||
<%= device.name %>
|
||||
</.link>
|
||||
</td>
|
||||
<td class="code"><%= device.ipv4 %></td>
|
||||
<td class="code"><%= device.ipv6 %></td>
|
||||
<td class="code"><%= device.public_key %></td>
|
||||
<td
|
||||
id={"device-#{device.id}-inserted-at"}
|
||||
data-timestamp={device.inserted_at}
|
||||
phx-hook="FormatTimestamp"
|
||||
>
|
||||
…
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p>
|
||||
<strong>No devices to show.</strong>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if Domain.Devices.authorize_user_device_management(@current_user.id, @subject) == :ok do %>
|
||||
<div class="block">
|
||||
<.link replace={true} patch={~p"/user_devices/new"} class="button">
|
||||
Add Device
|
||||
</.link>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<h4 class="title is-4">VPN Session</h4>
|
||||
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="block">
|
||||
<%= if vpn_sessions_expire?() do %>
|
||||
<%= if vpn_expired?(@current_user) do %>
|
||||
<p>
|
||||
Your VPN session expired at:
|
||||
</p>
|
||||
<% else %>
|
||||
<p>
|
||||
Your VPN session expires at:
|
||||
</p>
|
||||
<% end %>
|
||||
<p>
|
||||
<strong>
|
||||
<span
|
||||
id="vpn-expires"
|
||||
phx-hook="FormatTimestamp"
|
||||
data-timestamp={vpn_expires_at(@current_user)}
|
||||
>
|
||||
...
|
||||
</span>
|
||||
</strong>
|
||||
</p>
|
||||
<%= link("Reauthenticate", to: ~p"/sign_out", method: :delete) %> to renew your VPN session.
|
||||
<% else %>
|
||||
Your VPN session is active indefinitely.
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
<div class="block">
|
||||
<p>
|
||||
Signed in as <%= @current_user.email %>.
|
||||
</p>
|
||||
<p>
|
||||
<.link navigate={~p"/user_account"}>
|
||||
My Account
|
||||
</.link>
|
||||
/
|
||||
<%= link(to: ~p"/sign_out", method: :delete) do %>
|
||||
Sign out
|
||||
<% end %>
|
||||
</p>
|
||||
<p>
|
||||
For any issues, <a href={"mailto:#{admin_email()}"}>contact your Firezone administrator</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user