Replace web app with a new one based on Tailwind and esbuild (#1568)

This commit is contained in:
Andrew Dryga
2023-04-20 17:31:37 -07:00
committed by GitHub
parent 58b8d5212f
commit a4022fcc42
268 changed files with 1624 additions and 18104 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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: [

View File

@@ -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
View File

@@ -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/

View File

@@ -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

View File

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

View File

@@ -1,8 +0,0 @@
const { config: prodConfig } = require('./config.prod')
module.exports.config = {
...prodConfig,
minify: false,
sourcemap: true,
watch: true,
}

View File

@@ -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',
}
}

View File

@@ -0,0 +1,5 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
/* This file is for your main application CSS */

View File

@@ -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";

View File

@@ -1 +0,0 @@
/* Email Styles */

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -1,4 +0,0 @@
const esbuild = require('esbuild')
const { config } = require(`./config.${process.argv[2]}`)
esbuild.build(config).catch(() => process.exit(1))

View File

@@ -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
View 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

View File

@@ -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 }

View File

@@ -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.")
}
})

View File

@@ -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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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

View File

@@ -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"

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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

View File

@@ -1,2 +0,0 @@
# Auto detect text files and perform LF normalization
* text=auto

View File

@@ -1,10 +0,0 @@
# Files
.DS_Store
npm-debug.log
# Folders
.idea/
node_modules
# Hot
hot

View File

@@ -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.

View File

@@ -1,131 +0,0 @@
# [Admin One HTML &mdash; Free Bulma Admin Dashboard](https://justboil.me/bulma-admin-template/one-html)
[![version](https://img.shields.io/github/v/release/vikdiesel/admin-one-bulma-dashboard)](https://justboil.me/bulma-admin-template/one-html) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://justboil.me/bulma-admin-template/one-html)
[![Free Bulma admin dashboard](https://justboil.me/images/one/repository-preview-html-hi-res.png)](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
* &hellip; 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 &copy; 2019-2020 JustBoil.me (https://justboil.me)
- Licensed under MIT
## Useful Links
- [JustBoil.me](https://justboil.me)
- [Bulma](https://bulma.io)

View File

@@ -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"
}
}

View File

@@ -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')
})
})

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -1,14 +0,0 @@
footer.footer {
.logo {
img {
width: auto;
height: $footer-logo-height;
}
}
}
@include mobile {
.footer-copyright {
text-align: center;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}
}
}

View File

@@ -1,3 +0,0 @@
section.section.is-main-section {
padding-top: $default-padding;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}
}
}

View File

@@ -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;
}
}
}
}
}
}
}

View File

@@ -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;

View File

@@ -1,3 +0,0 @@
.is-tiles-wrapper {
margin-bottom: $default-padding;
}

View File

@@ -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;
}
}
}
}
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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"
}
}

View File

@@ -1,5 +0,0 @@
module.exports = {
plugins: [
require('autoprefixer'),
]
}

View 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
View 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

View File

@@ -1,5 +0,0 @@
{
"skip_files": [
"test"
]
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -0,0 +1,5 @@
defmodule Web.Layouts do
use Web, :html
embed_templates "layouts/*"
end

View 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">&rarr;</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>

View 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>

View File

@@ -1,4 +1,4 @@
defmodule Web.DocHelpers do
defmodule Web.ControllerDocumentation do
def group(name, children) do
{:group, {name, children}}
end

View File

@@ -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

View File

@@ -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

View File

@@ -1,7 +0,0 @@
defmodule Web.BrowserController do
use Web, :controller
def config(conn, _params) do
render(conn, "browserconfig.xml")
end
end

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -0,0 +1,5 @@
defmodule Web.PageHTML do
use Web, :html
embed_templates "page_html/*"
end

View 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 &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
fill="#18181B"
/>
</svg>
Source Code
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix/blob/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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -1,2 +0,0 @@
<%= render(Web.SharedView, "heading.html", page_title: "Devices |> #{@page_title}") %>
<%= render(Web.SharedView, "show_device.html", assigns) %>

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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