Initial commit: login and users

This commit is contained in:
Dimitar Ivanov
2026-08-27 15:35:57 +03:00
commit 145f1e5f7b
97 changed files with 7262 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]
+52
View File
@@ -0,0 +1,52 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Temporary files, for example, from tests.
/tmp/
# Ignore package tarball (built via "mix hex.build").
dexi-*.tar
# Ignore assets that are produced by build tools.
/priv/static/assets/
# Ignore digested assets cache.
/priv/static/cache_manifest.json
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/
# Local environment files may contain database URLs, API keys, and mail credentials.
/.env
/.env.*
!/.env.example
# Elixir language-server and editor metadata.
/.elixir_ls/
/.lexical/
# Operating-system metadata.
.DS_Store
# Local logs and runtime PID files.
*.log
*.pid
+449
View File
@@ -0,0 +1,449 @@
This is a web application written using the Phoenix web framework.
## Project guidelines
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
### Phoenix v1.8 guidelines
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
- Anytime you run into errors with no `current_scope` assign:
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
custom classes must fully style the input
### JS and CSS guidelines
- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces.
- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`:
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/my_app_web";
- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
- **Never** use `@apply` when writing raw css
- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
- Out of the box **only the app.js and app.css bundles are supported**
- You cannot reference an external vendor'd script `src` or link `href` in the layouts
- You must import the vendor deps into app.js and app.css to use them
- **Never write inline <script>custom js</script> tags within templates**
### UI/UX & design guidelines
- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles
- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions)
- Ensure **clean typography, spacing, and layout balance** for a refined, premium look
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax**
**Never do this (invalid)**:
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
i = 0
mylist = ["blue", "green"]
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
## Mix guidelines
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
## Test guidelines
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
<!-- phoenix:elixir-end -->
<!-- phoenix:phoenix-start -->
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
<!-- phoenix:ecto-end -->
<!-- phoenix:html-start -->
## Phoenix HTML guidelines
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
**Never do this (invalid)**:
<%= if condition do %>
...
<% else if other_condition %>
...
<% end %>
Instead **always** do this:
<%= cond do %>
<% condition -> %>
...
<% condition2 -> %>
...
<% true -> %>
...
<% end %>
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
and **never** do this, since it's invalid (note the missing `[` and `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> Raises compile syntax error on invalid HEEx attr syntax
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
**Always** do this:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>
<!-- phoenix:html-end -->
<!-- phoenix:liveview-start -->
## Phoenix LiveView guidelines
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
- **Avoid LiveComponent's** unless you have a strong, specific need for them
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
### LiveView streams
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items - `stream(socket, :messages, [new_msg])`
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
- deleting items - `stream_delete(socket, :messages, msg)`
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @streams.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
along with the updated assign:
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
message = Chat.get_message!(message_id)
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
{:noreply,
socket
|> stream_insert(:messages, message)
|> assign(:editing_message_id, String.to_integer(message_id))
|> assign(:edit_form, edit_form)}
end
And in the template:
<div id="messages" phx-update="stream">
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
{message.username}
<%= if @editing_message_id == message.id do %>
<%!-- Edit mode --%>
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
...
</.form>
<% end %>
</div>
</div>
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView JavaScript interop
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
#### Inline colocated js hooks
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
when writing scripts inside the template**:
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
- colocated hooks are automatically integrated into the app.js bundle
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
#### External phx-hook
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
LiveSocket constructor:
const MyHook = {
mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { MyHook }
});
#### Pushing events between client and server
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
**Always** return or rebind the socket on `push_event/3` when pushing events:
# re-bind socket so we maintain event state to be pushed
socket = push_event(socket, "my_event", %{...})
# or return the modified socket directly:
def handle_event("some_event", _, socket) do
{:noreply, push_event(socket, "my_event", %{...})}
end
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
mounted() {
this.handleEvent("my_event", data => console.log("from server:", data));
}
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
mounted() {
this.el.addEventListener("click", e => {
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
})
}
Where the server handled it via:
def handle_event("my_event", %{"one" => 1}, socket) do
{:reply, %{two: 2}, socket}
end
### LiveView tests
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
### Form handling
#### Creating a form from params
If you want to create a form based on `handle_event` params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
#### Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to `to_form`:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
In the template, the form form assign can be passed to the `<.form>` function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
#### Avoiding form errors
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And **never** do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
<!-- phoenix:liveview-end -->
<!-- usage-rules-end -->
+18
View File
@@ -0,0 +1,18 @@
# Dexi
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://phoenix.hexdocs.pm/deployment.html).
## Learn more
* Official website: https://www.phoenixframework.org/
* Guides: https://phoenix.hexdocs.pm/overview.html
* Docs: https://phoenix.hexdocs.pm
* Forum: https://elixirforum.com/c/phoenix-forum
* Source: https://github.com/phoenixframework/phoenix
+108
View File
@@ -0,0 +1,108 @@
/* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */
@import "tailwindcss" source(none);
@import "phoenix-colocated/dexi/colocated.css";
@source "../css";
@source "../js";
@source "../../lib/dexi_web";
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
/* Required for Tailwind to automatically pick up changes in colocated CSS files in dev */
@source "../../_build/dev/phoenix-colocated/dexi/*/";
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
The heroicons installation itself is managed by your mix.exs */
@plugin "../vendor/heroicons";
/* Add variants based on LiveView classes */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
/* Use the data attribute for dark mode */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents }
/* This file is for your main application CSS */
@theme {
--color-midnight: #080d19;
--color-deep-slate: #111827;
--color-slate-blue: #182235;
--color-muted-steel: #29364d;
--color-electric-cyan: #22d3ee;
--color-bright-cyan: #67e8f9;
--color-soft-violet: #8b5cf6;
--color-cool-white: #f1f5f9;
--color-blue-grey: #94a3b8;
--color-positive: #22c55e;
--color-negative: #f43f5e;
--color-warning: #f59e0b;
--shadow-cyan-glow: 0 24px 80px color-mix(in srgb, var(--color-electric-cyan) 10%, transparent);
--shadow-modal: 0 28px 100px color-mix(in srgb, var(--color-midnight) 80%, transparent);
}
:root,
[data-theme="light"],
[data-theme="dark"] {
color-scheme: dark;
--color-base-100: var(--color-slate-blue);
--color-base-200: var(--color-deep-slate);
--color-base-300: var(--color-midnight);
--color-base-content: var(--color-cool-white);
--color-primary: var(--color-electric-cyan);
--color-primary-content: var(--color-midnight);
--color-secondary: var(--color-soft-violet);
--color-secondary-content: var(--color-cool-white);
--color-accent: var(--color-bright-cyan);
--color-accent-content: var(--color-midnight);
--color-neutral: var(--color-muted-steel);
--color-neutral-content: var(--color-cool-white);
--color-info: var(--color-electric-cyan);
--color-info-content: var(--color-midnight);
--color-success: var(--color-positive);
--color-success-content: var(--color-midnight);
--color-warning-content: var(--color-midnight);
--color-error: var(--color-negative);
--color-error-content: var(--color-cool-white);
}
@layer base {
html {
background: var(--color-midnight);
}
body {
min-height: 100vh;
background:
radial-gradient(circle at 12% 8%, color-mix(in srgb, var(--color-electric-cyan) 8%, transparent), transparent 30rem),
radial-gradient(circle at 88% 22%, color-mix(in srgb, var(--color-soft-violet) 10%, transparent), transparent 34rem),
var(--color-midnight);
color: var(--color-cool-white);
}
input,
textarea,
select {
border-color: var(--color-muted-steel);
background-color: var(--color-deep-slate);
color: var(--color-cool-white);
}
input::placeholder,
textarea::placeholder {
color: var(--color-blue-grey);
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--color-electric-cyan);
outline-color: var(--color-soft-violet);
}
}
+116
View File
@@ -0,0 +1,116 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
// To load it, simply add a second `<link>` to your `root.html.heex` file.
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/dexi"
import topbar from "../vendor/topbar"
const CopyToClipboard = {
mounted() {
this.defaultLabel = this.el.textContent.trim()
this.el.addEventListener("click", async () => {
const input = document.querySelector(this.el.dataset.copyTarget)
if (!input) return
try {
await navigator.clipboard.writeText(input.value)
} catch (_error) {
input.focus()
input.select()
document.execCommand("copy")
input.setSelectionRange(0, 0)
input.blur()
}
window.clearTimeout(this.resetLabelTimer)
this.el.textContent = "Copied"
this.resetLabelTimer = window.setTimeout(() => {
this.el.textContent = this.defaultLabel
}, 1600)
})
},
destroyed() {
window.clearTimeout(this.resetLabelTimer)
},
}
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, CopyToClipboard},
})
// Show progress bar on live navigation and form submits
topbar.config({
barColors: {0: "var(--color-electric-cyan)"},
shadowColor: "var(--color-midnight)",
})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
// The lines below enable quality of life phoenix_live_reload
// development features:
//
// 1. stream server logs to the browser console
// 2. click on elements to jump to their definitions in your code editor
//
if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
// Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs()
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
//
// * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location
let keyDown
window.addEventListener("keydown", e => keyDown = e.key)
window.addEventListener("keyup", _e => keyDown = null)
window.addEventListener("click", e => {
if(keyDown === "c"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtCaller(e.target)
} else if(keyDown === "d"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtDef(e.target)
}
}, true)
window.liveReloader = reloader
})
}
+1061
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
{
"dependencies": {
"@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.3.3"
}
}
+32
View File
@@ -0,0 +1,32 @@
// This file is needed on most editors to enable the intelligent autocompletion
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
//
// Note: This file assumes a basic esbuild setup without node_modules.
// We include a generic paths alias to deps to mimic how esbuild resolves
// the Phoenix and LiveView JavaScript assets.
// If you have a package.json in your project, you should remove the
// paths configuration and instead add the phoenix dependencies to the
// dependencies section of your package.json:
//
// {
// ...
// "dependencies": {
// ...,
// "phoenix": "../deps/phoenix",
// "phoenix_html": "../deps/phoenix_html",
// "phoenix_live_view": "../deps/phoenix_live_view"
// }
// }
//
// Feel free to adjust this configuration however you need.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": ["../deps/*"]
},
"allowJs": true,
"noEmit": true
},
"include": ["js/**/*"]
}
+43
View File
@@ -0,0 +1,43 @@
const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")
module.exports = plugin(function({matchComponents, theme}) {
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
let values = {}
let icons = [
["", "/24/outline"],
["-solid", "/24/solid"],
["-mini", "/20/solid"],
["-micro", "/16/solid"]
]
icons.forEach(([suffix, dir]) => {
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
let name = path.basename(file, ".svg") + suffix
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
})
})
matchComponents({
"hero": ({name, fullPath}) => {
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
content = encodeURIComponent(content)
let size = theme("spacing.6")
if (name.endsWith("-mini")) {
size = theme("spacing.5")
} else if (name.endsWith("-micro")) {
size = theme("spacing.4")
}
return {
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
"-webkit-mask": `var(--hero-${name})`,
"mask": `var(--hero-${name})`,
"mask-repeat": "no-repeat",
"background-color": "currentColor",
"vertical-align": "middle",
"display": "inline-block",
"width": size,
"height": size
}
}
}, {values})
})
+138
View File
@@ -0,0 +1,138 @@
/**
* @license MIT
* topbar 3.0.0
* http://buunguyen.github.io/topbar
* Copyright (c) 2024 Buu Nguyen
*/
(function (window, document) {
"use strict";
var canvas,
currentProgress,
showing,
progressTimerId = null,
fadeTimerId = null,
delayTimerId = null,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function (delay) {
if (showing) return;
if (delay) {
if (delayTimerId) return;
delayTimerId = setTimeout(() => topbar.show(), delay);
} else {
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
if (!canvas.parentElement) document.body.appendChild(canvas);
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
clearTimeout(delayTimerId);
delayTimerId = null;
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));
+75
View File
@@ -0,0 +1,75 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
config :dexi,
ecto_repos: [Dexi.Repo],
generators: [timestamp_type: :utc_datetime],
host: "http://localhost:4000",
login_url_refresh_interval_in_milliseconds: 60_000,
chain_node_addresses: "",
email_from: {"Dexi", "noreply@dexi.local"}
# Configure the endpoint
config :dexi, DexiWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: DexiWeb.ErrorHTML, json: DexiWeb.ErrorJSON],
layout: false
],
pubsub_server: Dexi.PubSub,
live_view: [signing_salt: "lLIRUjp4"]
# Configure LiveView
config :phoenix_live_view,
# the attribute set on all root tags. Used for Phoenix.LiveView.ColocatedCSS.
root_tag_attribute: "phx-r"
# Configure the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :dexi, Dexi.Mailer, adapter: Swoosh.Adapters.Local
# Configure esbuild (the version is required)
config :esbuild,
version: "0.25.4",
dexi: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]
# Configure tailwind (the version is required)
config :tailwind,
version: "4.3.0",
dexi: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/css/app.css
),
cd: Path.expand("..", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]
# Configure Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
+97
View File
@@ -0,0 +1,97 @@
import Config
# Configure your database
config :dexi, Dexi.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "dexi_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it
# to bundle .js and .css sources.
config :dexi, DexiWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "S8w/n1pTgXykkU72GIp40MvPE4qk7Rg89zExaT8xm9B0D56Xz5toOs91hl+bcMcY",
watchers: [
esbuild: {Esbuild, :install_and_run, [:dexi, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:dexi, ~w(--watch)]}
]
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Reload browser tabs when matching files change.
config :dexi, DexiWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
# Static assets, except user uploads
~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$",
# Gettext translations
~r"priv/gettext/.*\.po$",
# Router, Controllers, LiveViews and LiveComponents
~r"lib/dexi_web/router\.ex$",
~r"lib/dexi_web/(controllers|live|components)/.*\.(ex|heex)$"
]
]
# Enable dev routes for dashboard and mailbox
config :dexi, dev_routes: true
config :dexi, chain_node_addresses: "118.243.19.215:4013"
# Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
# Include debug annotations and locations in rendered markup.
# Changing this configuration will require mix clean and a full recompile.
debug_heex_annotations: true,
debug_attributes: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false
config :dexi, :allow_test_users, true
config :dexi, :email_confirmation_pepper, "dexi-development-email-confirmation-pepper"
+32
View File
@@ -0,0 +1,32 @@
import Config
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix assets.deploy` task,
# which you should run after static files are built and
# before starting your production server.
config :dexi, DexiWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
# Force using SSL in production. This also sets the "strict-security-transport" header,
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
# Note `:force_ssl` is required to be set at compile-time.
config :dexi, DexiWeb.Endpoint,
force_ssl: [
rewrite_on: [:x_forwarded_proto],
exclude: [
# paths: ["/health"],
hosts: ["localhost", "127.0.0.1"]
]
]
# Configure Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req
# Disable Swoosh Local Memory Storage
config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.
+143
View File
@@ -0,0 +1,143 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/dexi start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :dexi, DexiWeb.Endpoint, server: true
end
config :dexi, DexiWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))]
if dexi_host = System.get_env("DEXI_HOST") do
config :dexi, host: dexi_host
end
if network_id = System.get_env("DEXI_NETWORK_ID") do
config :dexi, network_id: network_id
end
if email_from = System.get_env("DEXI_EMAIL_FROM") do
config :dexi, email_from: {"Dexi", email_from}
end
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :dexi, Dexi.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
# For machines with several cores, consider starting multiple pools of `pool_size`
# pool_count: 4,
socket_options: maybe_ipv6
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
config :dexi,
host: System.get_env("DEXI_HOST", "https://#{host}"),
chain_node_addresses: System.get_env("CHAIN_NODE_ADDRESSES") || ""
config :dexi, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :dexi, DexiWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://bandit.hexdocs.pm/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0}
],
secret_key_base: secret_key_base
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :dexi, DexiWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://plug.hexdocs.pm/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :dexi, DexiWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Here is an example configuration for Mailgun:
#
# config :dexi, Dexi.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
# and Finch out-of-the-box. This configuration is typically done at
# compile-time in your config/prod.exs:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://swoosh.hexdocs.pm/Swoosh.html#module-installation for details.
end
if config_env() == :prod do
config :dexi, :email_confirmation_pepper, System.fetch_env!("EMAIL_CONFIRMATION_PEPPER")
config :dexi,
:allow_test_users,
System.get_env("DEXI_ALLOW_TEST_USERS", "false") in ["1", "true", "TRUE"]
end
+49
View File
@@ -0,0 +1,49 @@
import Config
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :dexi, Dexi.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "dexi_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :dexi, DexiWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "OD3ogSvbggrdhOLbKgOoYIS/j/o+8t1FWrpSL7J0IzxrM/Dn8X2dyepvFBaUrUYn",
server: false
# In test we don't send emails
config :dexi, Dexi.Mailer, adapter: Swoosh.Adapters.Test
config :dexi,
host: "http://localhost:4002",
network_id: "test-network-id",
chain_node_addresses: "127.0.0.1:6000"
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
enable_expensive_runtime_checks: true
# Sort query params output of verified routes for robust url comparisons
config :phoenix,
sort_verified_routes_query_params: true
config :dexi, :allow_test_users, true
config :dexi, :email_confirmation_pepper, "dexi-test-email-confirmation-pepper"
+61
View File
@@ -0,0 +1,61 @@
# Command-line interface
Dexi release commands are run through the release executable, usually `bin/dexi`.
## Database migrations
```sh
bin/dexi migrate
```
Runs all pending database migrations. Run this command after deploying a release that contains new migrations.
For local development, use:
```sh
mix ecto.migrate
```
## Create or promote an administrator
```sh
bin/dexi admin --create <pubkey>
```
Creates an administrator with the supplied public key. If the user already exists, their role is changed to `admin`. Running the command more than once is safe.
For local development, use:
```sh
mix dexi.admin --create <pubkey>
```
## Remove administrator access
```sh
bin/dexi admin --remove <pubkey>
```
Changes the existing user's role from `admin` to `user`. The user account and profile information are preserved.
For local development, use:
```sh
mix dexi.admin --remove <pubkey>
```
## Create test users
```sh
bin/dexi test_users --count <count>
```
Creates the requested number of test users with generated public keys, names, and email addresses. A single invocation accepts between 1 and 10,000 users.
Production releases disable this command by default. Set `DEXI_ALLOW_TEST_USERS=true` only when synthetic users are intentionally required.
For local development, use:
```sh
mix dexi.test_users --count <count>
```
+9
View File
@@ -0,0 +1,9 @@
defmodule Dexi do
@moduledoc """
Dexi keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end
+73
View File
@@ -0,0 +1,73 @@
defmodule Dexi.Accounts do
@moduledoc "Account management for wallet-authenticated users."
alias Dexi.Accounts.Administration
alias Dexi.Accounts.EmailConfirmation
alias Dexi.Accounts.Search
alias Dexi.Accounts.User
alias Dexi.Repo
def list_users, do: Search.search_users("")
def search_users(term, role \\ :all), do: Search.search_users(term, role)
def search_page(term, role \\ :all, options \\ []), do: Search.search_page(term, role, options)
def normalize_search_role(role), do: Search.normalize_role(role)
def get_user_by_pubkey(pubkey) when is_binary(pubkey), do: Repo.get(User, pubkey)
def get_user_by_pubkey(_pubkey), do: nil
def get_or_create_user(pubkey) when is_binary(pubkey) do
case get_or_create_user_with_status(pubkey) do
{:ok, user, _status} -> {:ok, user}
{:error, changeset} -> {:error, changeset}
end
end
def get_or_create_user_with_status(pubkey) when is_binary(pubkey) do
case get_user_by_pubkey(pubkey) do
%User{} = user ->
{:ok, user, :existing}
nil ->
%User{}
|> User.registration_changeset(%{pubkey: pubkey, role: :user})
|> Repo.insert()
|> resolve_registration(pubkey)
end
end
def change_profile(%User{} = user, attrs \\ %{}), do: User.profile_changeset(user, attrs)
def update_profile(%User{} = user, attrs) do
user
|> User.profile_changeset(attrs)
|> Repo.update()
end
def create_or_promote_admin(pubkey, attrs \\ %{}),
do: Administration.create_or_promote_admin(pubkey, attrs)
def remove_admin(pubkey), do: Administration.remove_admin(pubkey)
def set_role(actor, target, role), do: Administration.set_role(actor, target, role)
def update_user_details(actor, target, attrs),
do: Administration.update_user_details(actor, target, attrs)
def request_email_confirmation(user), do: EmailConfirmation.request(user)
def confirm_email(user, code), do: EmailConfirmation.confirm(user, code)
def email_confirmed?(user), do: EmailConfirmation.confirmed?(user)
def email_confirmation_active?(user), do: EmailConfirmation.active?(user)
def clear_expired_email_confirmation(user), do: EmailConfirmation.clear_expired(user)
defp resolve_registration({:ok, user}, _pubkey), do: {:ok, user, :created}
defp resolve_registration({:error, changeset} = error, pubkey) do
if changeset.errors[:pubkey] do
case get_user_by_pubkey(pubkey) do
%User{} = user -> {:ok, user, :existing}
nil -> error
end
else
error
end
end
end
+34
View File
@@ -0,0 +1,34 @@
defmodule Dexi.Accounts.AdminCommand do
@moduledoc false
alias Dexi.Accounts
def run("--create", pubkey) do
case Accounts.create_or_promote_admin(pubkey) do
{:ok, user} -> {:ok, "Admin user ready: #{user.pubkey}"}
{:error, changeset} -> {:error, format_errors(changeset)}
end
end
def run("--remove", pubkey) do
case Accounts.remove_admin(pubkey) do
{:ok, user} -> {:ok, "Admin access removed: #{user.pubkey}"}
{:error, :not_found} -> {:error, "User not found: #{pubkey}"}
{:error, changeset} -> {:error, format_errors(changeset)}
end
end
def run(_operation, _pubkey),
do: {:error, "Expected --create or --remove followed by a public key"}
defp format_errors(%Ecto.Changeset{} = changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, options} ->
Enum.reduce(options, message, fn {key, value}, result ->
String.replace(result, "%{#{key}}", to_string(value))
end)
end)
|> Enum.map_join(", ", fn {field, messages} -> "#{field} #{Enum.join(messages, ", ")}" end)
end
defp format_errors(reason), do: inspect(reason)
end
+84
View File
@@ -0,0 +1,84 @@
defmodule Dexi.Accounts.Administration do
@moduledoc false
alias Dexi.Accounts.User
alias Dexi.Repo
def create_or_promote_admin(pubkey), do: create_or_promote_admin(pubkey, %{})
def create_or_promote_admin(pubkey, attrs) do
attrs = stringify_keys(attrs)
case Repo.get(User, pubkey) do
nil ->
%User{}
|> User.admin_registration_changeset(
Map.merge(attrs, %{"pubkey" => pubkey, "role" => :admin})
)
|> Repo.insert()
user ->
user
|> User.profile_changeset(attrs)
|> Ecto.Changeset.put_change(:role, :admin)
|> Repo.update()
end
end
def remove_admin(pubkey) do
case Repo.get(User, pubkey) do
nil -> {:error, :not_found}
user -> update_role(user, :user)
end
end
def set_role(%User{pubkey: actor_pubkey}, %User{pubkey: target_pubkey}, role)
when role in [:user, :admin] do
with {:ok, actor} <- fetch_admin(actor_pubkey),
{:ok, target} <- fetch_user(target_pubkey),
:ok <- allow_role_change(actor, target, role) do
update_role(target, role)
end
end
def set_role(%User{}, %User{}, _role), do: {:error, :invalid_role}
def update_user_details(%User{pubkey: actor_pubkey}, %User{pubkey: target_pubkey}, attrs) do
with {:ok, _actor} <- fetch_admin(actor_pubkey),
{:ok, target} <- fetch_user(target_pubkey) do
target
|> User.profile_changeset(attrs)
|> Repo.update()
end
end
defp fetch_admin(pubkey) do
case Repo.get(User, pubkey) do
%User{role: :admin} = user -> {:ok, user}
%User{} -> {:error, :unauthorized}
nil -> {:error, :unauthorized}
end
end
defp fetch_user(pubkey) do
case Repo.get(User, pubkey) do
%User{} = user -> {:ok, user}
nil -> {:error, :not_found}
end
end
defp allow_role_change(actor, target, :user) when actor.pubkey == target.pubkey,
do: {:error, :self_demotion}
defp allow_role_change(_actor, _target, _role), do: :ok
defp update_role(user, role) do
user
|> Ecto.Changeset.change(role: role)
|> Repo.update()
end
defp stringify_keys(attrs) do
Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
end
end
+149
View File
@@ -0,0 +1,149 @@
defmodule Dexi.Accounts.EmailConfirmation do
@moduledoc false
alias Dexi.Accounts.User
alias Dexi.Accounts.UserNotifier
alias Dexi.Repo
@validity_seconds 15 * 60
@resend_seconds 60
def request(%User{email: nil}), do: {:error, :email_not_set}
def request(%User{email_confirmed_at: confirmed_at}) when not is_nil(confirmed_at),
do: {:error, :already_confirmed}
def request(%User{} = user) do
if resend_available?(user) do
code = generate_code()
changes = %{
email_confirmation_code_hash: hash(code),
email_confirmation_sent_at: DateTime.utc_now(:second)
}
case user |> User.email_confirmation_changeset(changes) |> Repo.update() do
{:ok, updated_user} -> deliver(updated_user, code)
{:error, changeset} -> {:error, changeset}
end
else
{:error, :rate_limited}
end
end
def confirm(%User{} = user, code) when is_binary(code) do
code = String.trim(code)
cond do
is_nil(user.email) -> {:error, :email_not_set}
confirmed?(user) -> {:error, :already_confirmed}
not active?(user) -> inactive_error(user)
Plug.Crypto.secure_compare(hash(code), user.email_confirmation_code_hash) -> confirm(user)
true -> {:error, :invalid_code}
end
end
def confirmed?(%User{email: nil}), do: false
def confirmed?(%User{email_confirmed_at: nil}), do: false
def confirmed?(%User{}), do: true
def active?(%User{
email: email,
email_confirmed_at: nil,
email_confirmation_code_hash: hash,
email_confirmation_sent_at: sent_at
})
when is_binary(email) and is_binary(hash) and not is_nil(sent_at) do
age = DateTime.diff(DateTime.utc_now(:second), sent_at, :second)
age >= 0 and age < @validity_seconds
end
def active?(%User{}), do: false
defp deliver(user, code) do
case UserNotifier.deliver_email_confirmation(user, code) do
{:ok, _email} ->
{:ok, user}
{:error, reason} ->
_ = clear(user)
{:error, {:delivery_failed, reason}}
end
end
defp confirm(user) do
user
|> User.email_confirmation_changeset(%{
email_confirmed_at: DateTime.utc_now(:second),
email_confirmation_code_hash: nil,
email_confirmation_sent_at: nil
})
|> Repo.update()
end
defp inactive_error(
%User{
email_confirmation_code_hash: hash,
email_confirmation_sent_at: sent_at
} = user
)
when not is_nil(hash) and not is_nil(sent_at) do
if expired?(user) do
_ = clear(user)
{:error, :expired}
else
{:error, :invalid_timestamp}
end
end
defp inactive_error(%User{}), do: {:error, :code_not_requested}
defp resend_available?(%User{email_confirmation_sent_at: nil}), do: true
defp resend_available?(%User{email_confirmation_sent_at: sent_at}) do
DateTime.diff(DateTime.utc_now(:second), sent_at, :second) >= @resend_seconds
end
defp generate_code do
4
|> :crypto.strong_rand_bytes()
|> :binary.decode_unsigned()
|> rem(1_000_000)
|> Integer.to_string()
|> String.pad_leading(6, "0")
end
defp hash(code), do: hash_code(code)
defp clear(user) do
user
|> User.email_confirmation_changeset(%{
email_confirmation_code_hash: nil,
email_confirmation_sent_at: nil
})
|> Repo.update()
end
def clear_expired(%User{} = user) do
if expired?(user) do
clear(user)
else
{:ok, user}
end
end
defp expired?(%User{email_confirmation_code_hash: hash, email_confirmation_sent_at: sent_at})
when is_binary(hash) and not is_nil(sent_at) do
DateTime.diff(DateTime.utc_now(:second), sent_at, :second) >= @validity_seconds
end
defp expired?(_user), do: false
defp hash_code(code) do
:crypto.mac(:hmac, :sha256, confirmation_pepper(), code)
end
defp confirmation_pepper do
Application.fetch_env!(:dexi, :email_confirmation_pepper)
end
end
+10
View File
@@ -0,0 +1,10 @@
defmodule Dexi.Accounts.Scope do
@moduledoc false
alias Dexi.Accounts.User
defstruct user: nil
def for_user(%User{} = user), do: %__MODULE__{user: user}
def for_user(nil), do: %__MODULE__{}
end
+120
View File
@@ -0,0 +1,120 @@
defmodule Dexi.Accounts.Search do
@moduledoc false
import Ecto.Query
alias Dexi.Accounts.{SearchPage, User}
alias Dexi.Repo
@default_per_page 20
@maximum_per_page 100
@roles [:all, :user, :admin]
def list_users, do: search_users("", :all)
def search_users(term, role \\ :all) do
term
|> query(role)
|> order_by([user], asc: user.inserted_at, asc: user.pubkey)
|> Repo.all()
end
def search_page(term, role \\ :all, options \\ []) do
requested_page = options |> Keyword.get(:page, 1) |> normalize_page()
per_page = options |> Keyword.get(:per_page, @default_per_page) |> normalize_per_page()
query = query(term, role)
total_entries = Repo.aggregate(query, :count, :pubkey)
total_pages = max(div(total_entries + per_page - 1, per_page), 1)
page = min(requested_page, total_pages)
entries =
query
|> order_by([user], asc: user.inserted_at, asc: user.pubkey)
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> Repo.all()
%SearchPage{
entries: entries,
page: page,
per_page: per_page,
total_entries: total_entries,
total_pages: total_pages
}
end
def normalize_role(role) when role in @roles, do: role
def normalize_role("all"), do: :all
def normalize_role("user"), do: :user
def normalize_role("admin"), do: :admin
def normalize_role(_role), do: :all
def search_mode(term) when is_binary(term), do: term |> String.downcase() |> do_search_mode()
defp query(term, role) do
User
|> filter_role(normalize_role(role))
|> filter_term(String.trim(term || ""))
end
defp filter_role(query, :all), do: query
defp filter_role(query, role), do: where(query, [user], user.role == ^role)
defp filter_term(query, ""), do: query
defp filter_term(query, term) do
normalized = String.downcase(term)
case search_mode(normalized) do
:all_fields ->
prefix = like_prefix(normalized)
contains = like_contains(normalized)
where(
query,
[user],
fragment("lower(?) LIKE ? ESCAPE E'\\\\'", user.pubkey, ^prefix) or
fragment("lower(coalesce(?, '')) LIKE ? ESCAPE E'\\\\'", user.name, ^contains) or
fragment("lower(coalesce(?, '')) LIKE ? ESCAPE E'\\\\'", user.email, ^contains)
)
:public_key ->
prefix = like_prefix(normalized)
where(query, [user], fragment("lower(?) LIKE ? ESCAPE E'\\\\'", user.pubkey, ^prefix))
:identity ->
contains = like_contains(normalized)
where(
query,
[user],
fragment("lower(coalesce(?, '')) LIKE ? ESCAPE E'\\\\'", user.name, ^contains) or
fragment("lower(coalesce(?, '')) LIKE ? ESCAPE E'\\\\'", user.email, ^contains)
)
end
end
defp do_search_mode(""), do: :all
defp do_search_mode(term) when term in ["a", "ak"], do: :all_fields
defp do_search_mode("ak_" <> _rest), do: :public_key
defp do_search_mode(_term), do: :identity
defp like_prefix(term), do: escape_like(term) <> "%"
defp like_contains(term), do: "%" <> escape_like(term) <> "%"
defp escape_like(term) do
term
|> String.replace("\\", "\\\\")
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
end
defp normalize_page(page) when is_integer(page) and page > 0, do: page
defp normalize_page(_page), do: 1
defp normalize_per_page(per_page) when is_integer(per_page) do
per_page |> max(1) |> min(@maximum_per_page)
end
defp normalize_per_page(_per_page), do: @default_per_page
end
+5
View File
@@ -0,0 +1,5 @@
defmodule Dexi.Accounts.SearchPage do
@moduledoc false
defstruct entries: [], page: 1, per_page: 25, total_entries: 0, total_pages: 1
end
+50
View File
@@ -0,0 +1,50 @@
defmodule Dexi.Accounts.TestUsers do
@moduledoc false
alias Dexi.Accounts.User
alias Dexi.Repo
@maximum_count 10_000
@batch_size 500
def create(count) when is_integer(count) and count > 0 and count <= @maximum_count do
if Application.get_env(:dexi, :allow_test_users, false) do
{:ok, insert_users(count)}
else
{:error, "test-user creation is disabled; set DEXI_ALLOW_TEST_USERS=true to enable it"}
end
end
def create(_count), do: {:error, "count must be between 1 and #{@maximum_count}"}
defp insert_users(count) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
batch = System.unique_integer([:positive, :monotonic])
1..count
|> Stream.map(&entry(&1, batch, now))
|> Stream.chunk_every(@batch_size)
|> Enum.flat_map(fn entries ->
{_inserted, users} = Repo.insert_all(User, entries, returning: true)
users
end)
end
defp entry(index, batch, now) do
suffix = "#{batch}_#{index}"
%{
pubkey: test_public_key(),
email: "test-user-#{suffix}@example.invalid",
name: "Test User #{suffix}",
role: :user,
inserted_at: now,
updated_at: now
}
end
defp test_public_key do
{pubkey, _key_pair} = :hz_key_master.make_key(:crypto.strong_rand_bytes(32))
to_string(pubkey)
end
end
+104
View File
@@ -0,0 +1,104 @@
defmodule Dexi.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:pubkey, :string, autogenerate: false}
@foreign_key_type :string
schema "users" do
field :email, :string
field :name, :string
field :role, Ecto.Enum, values: [:user, :admin], default: :user
field :email_confirmed_at, :utc_datetime
field :email_confirmation_code_hash, :binary, redact: true
field :email_confirmation_sent_at, :utc_datetime
timestamps(type: :utc_datetime)
end
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:pubkey, :role])
|> validate_required([:pubkey, :role])
|> validate_pubkey()
|> unique_constraint(:pubkey)
end
def admin_registration_changeset(user, attrs) do
user
|> cast(attrs, [:pubkey, :email, :name, :role])
|> validate_required([:pubkey, :role])
|> validate_profile()
|> maybe_invalidate_email_confirmation()
|> validate_pubkey()
|> unique_constraint(:pubkey)
|> unique_constraint(:email)
end
def profile_changeset(user, attrs) do
user
|> cast(attrs, [:email, :name])
|> validate_profile()
|> maybe_invalidate_email_confirmation()
|> unique_constraint(:email)
end
def email_confirmation_changeset(user, attrs) do
change(user, attrs)
end
def role_changeset(user, attrs) do
user
|> cast(attrs, [:role])
|> validate_required([:role])
end
defp validate_profile(changeset) do
changeset
|> update_change(:email, &normalize_email/1)
|> update_change(:name, &normalize_optional/1)
|> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)
|> validate_length(:email, max: 320)
|> validate_length(:name, max: 120)
end
defp validate_pubkey(changeset) do
validate_change(changeset, :pubkey, fn :pubkey, pubkey ->
case :gmser_api_encoder.safe_decode(:account_pubkey, pubkey) do
{:ok, _decoded_pubkey} -> []
_error -> [pubkey: "is not a valid account public key"]
end
end)
end
defp maybe_invalidate_email_confirmation(changeset) do
case get_change(changeset, :email, :unchanged) do
:unchanged ->
changeset
_changed_email ->
changeset
|> put_change(:email_confirmed_at, nil)
|> put_change(:email_confirmation_code_hash, nil)
|> put_change(:email_confirmation_sent_at, nil)
end
end
defp normalize_optional(nil), do: nil
defp normalize_optional(value) do
case String.trim(value) do
"" -> nil
trimmed -> trimmed
end
end
defp normalize_email(nil), do: nil
defp normalize_email(value) do
case String.trim(value) do
"" -> nil
trimmed -> String.downcase(trimmed)
end
end
end
+30
View File
@@ -0,0 +1,30 @@
defmodule Dexi.Accounts.UserNotifier do
@moduledoc false
import Swoosh.Email
alias Dexi.Mailer
def deliver_email_confirmation(user, code) do
new()
|> to({user.name || user.email, user.email})
|> from(Application.fetch_env!(:dexi, :email_from))
|> subject("Your Dexi verification code: #{code}")
|> text_body("""
Confirm your email address for Dexi with this code:
#{code}
This code expires in 15 minutes. If you did not request it, you can ignore this email.
""")
|> html_body("""
<div style="font-family: sans-serif; color: #2e1065; padding: 24px">
<h1 style="margin: 0 0 16px">Confirm your email</h1>
<p>Enter this code in Dexi:</p>
<p style="font-size: 32px; font-weight: 700; letter-spacing: 8px">#{code}</p>
<p>This code expires in 15 minutes. If you did not request it, you can ignore this email.</p>
</div>
""")
|> Mailer.deliver()
end
end
+52
View File
@@ -0,0 +1,52 @@
defmodule Dexi.Application do
# See https://elixir.hexdocs.pm/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
:ok = :hz.chain_nodes(chain_node_addresses())
children = [
DexiWeb.Telemetry,
Dexi.Repo,
Dexi.GridsLoginHandler,
Dexi.GridsCallData,
{DNSCluster, query: Application.get_env(:dexi, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Dexi.PubSub},
# Start a worker by calling: Dexi.Worker.start_link(arg)
# {Dexi.Worker, arg},
# Start to serve requests, typically the last entry
DexiWeb.Endpoint
]
# See https://elixir.hexdocs.pm/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Dexi.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
DexiWeb.Endpoint.config_change(changed, removed)
:ok
end
defp chain_node_addresses do
:dexi
|> Application.get_env(:chain_node_addresses, "")
|> String.split(",", trim: true)
|> Enum.map(&parse_chain_node_address/1)
end
defp parse_chain_node_address(node_address) do
[ip, port] = node_address |> String.trim() |> String.split(":")
{:ok, parsed_ip} = :inet.parse_address(String.to_charlist(ip))
{parsed_ip, String.to_integer(port)}
end
end
+78
View File
@@ -0,0 +1,78 @@
defmodule Dexi.ChainTransactions do
@moduledoc "Verification and submission of wallet-signed Gajumaru transactions."
require Logger
alias Dexi.Grids
def verify_signed_tx(pubkey, unsigned_tx, signed_tx) do
with {:ok, network_id} <- Grids.network_id(),
{:ok, unsigned_tx_data} <- decode_transaction(unsigned_tx),
{:ok, signed_tx_data} <- decode_transaction(signed_tx),
{:ok, signatures, ^unsigned_tx_data} <- decode_signed_tx(signed_tx_data),
{:ok, pubkey_data} <- decode_pubkey(pubkey),
true <- valid_tx_signature?(signatures, unsigned_tx_data, pubkey_data, network_id) do
:ok
else
{:ok, _signatures, _other_tx_data} -> {:error, :transaction_mismatch}
false -> {:error, :signature_not_verified}
{:error, reason} -> {:error, reason}
end
rescue
error ->
Logger.info(Exception.format(:error, error, __STACKTRACE__))
{:error, :invalid_signed_tx}
end
def post_tx(signed_tx) do
case :hz.post_tx(signed_tx) do
{:ok, %{~c"tx_hash" => tx_hash}} ->
{:ok, to_string(tx_hash)}
{:ok, %{"tx_hash" => tx_hash}} ->
{:ok, to_string(tx_hash)}
{:error, reason} ->
{:error, reason}
other ->
Logger.error("Unexpected transaction submission response: #{inspect(other)}")
{:error, :unknown_error}
end
end
defp decode_transaction(transaction) when is_binary(transaction) do
:gmser_api_encoder.safe_decode(:transaction, transaction)
end
defp decode_signed_tx(signed_tx_data) do
case :gmser_chain_objects.deserialize_type_and_vsn(signed_tx_data) do
{:signed_tx, 1, _fields} ->
fields =
:gmser_chain_objects.deserialize(
:signed_tx,
1,
[{:signatures, [:binary]}, {:transaction, :binary}],
signed_tx_data
)
{:ok, Keyword.fetch!(fields, :signatures), Keyword.fetch!(fields, :transaction)}
{type, version, _fields} ->
{:error, {:unexpected_tx_type, type, version}}
end
end
defp decode_pubkey(pubkey) do
:gmser_api_encoder.safe_decode(:account_pubkey, pubkey)
end
defp valid_tx_signature?(signatures, unsigned_tx_data, pubkey_data, network_id) do
{:ok, tx_hash} = :eblake2.blake2b(32, unsigned_tx_data)
signed_payload = network_id <> tx_hash
Enum.any?(signatures, fn signature ->
:ecu_eddsa.sign_verify_detached(signature, signed_payload, pubkey_data)
end)
end
end
+23
View File
@@ -0,0 +1,23 @@
defmodule Dexi.CLI.Arguments do
@moduledoc false
@admin_operations ["--create", "--remove"]
def parse_admin([operation, pubkey]) when operation in @admin_operations and pubkey != "" do
{:ok, operation, pubkey}
end
def parse_admin(_arguments) do
{:error, "Expected --create or --remove followed by a public key"}
end
def parse_count(["--count", count]), do: parse_positive_integer(count)
def parse_count(_arguments), do: {:error, "Expected --count COUNT"}
defp parse_positive_integer(value) do
case Integer.parse(value) do
{count, ""} when count > 0 -> {:ok, count}
_error -> {:error, "COUNT must be a positive integer"}
end
end
end
+61
View File
@@ -0,0 +1,61 @@
defmodule Dexi.Grids do
@moduledoc "GRIDS dead-drop creation and Gajumaru signature verification."
require Logger
alias Dexi.Grids.DeadDrop
alias Ecto.Changeset
def create_dead_drop(attrs) do
%DeadDrop{}
|> DeadDrop.changeset(attrs)
|> Changeset.apply_action(:create)
end
def verify_dead_drop_signature(
%DeadDrop{
signature: signature,
payload: payload,
public_id: public_id,
network_id: network_id
} = dead_drop
) do
with {:ok, expected_network_id} <- network_id(),
true <- expected_network_id == network_id,
{:ok, true} <- :hz.verify_signature(signature, payload, public_id) do
{:ok, dead_drop}
else
false -> {:error, :invalid_network_id}
{:ok, false} -> {:error, :signature_not_verified}
{:error, reason} -> {:error, reason}
end
rescue
error ->
Logger.info(Exception.format(:error, error, __STACKTRACE__))
{:error, :signature_verification_failed}
end
def generate_sign_grids_url(message_id) do
host = Application.fetch_env!(:dexi, :host)
"#{String.replace(host, "http", "grid", global: false)}/1/d/api/signature/#{message_id}"
end
def generate_sign_call_grids_url(message_id) do
host = Application.fetch_env!(:dexi, :host)
"#{String.replace(host, "http", "grid", global: false)}/1/d/api/sign/#{message_id}"
end
def network_id do
case Application.get_env(:dexi, :network_id) do
network_id when is_binary(network_id) and network_id != "" -> {:ok, network_id}
_unset -> fetch_hakuzaru_network_id()
end
end
defp fetch_hakuzaru_network_id do
case :hz.network_id() do
{:ok, network_id} -> {:ok, to_string(network_id)}
{:error, _reason} = error -> error
end
end
end
+25
View File
@@ -0,0 +1,25 @@
defmodule Dexi.Grids.DeadDrop do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
alias Dexi.Grids.PublicId
@primary_key false
embedded_schema do
field :type, :string, default: "message"
field :signature, :string
field :payload, :string
field :grids, :integer, default: 1
field :chain, :string, default: "gajumaru"
field :network_id, :string
field :public_id, PublicId, default: false
end
def changeset(dead_drop, attrs) do
dead_drop
|> cast(attrs, [:grids, :chain, :network_id, :type, :public_id, :payload, :signature])
|> validate_required([:grids, :chain, :network_id, :type, :payload])
end
end
+13
View File
@@ -0,0 +1,13 @@
defmodule Dexi.Grids.PublicId do
@moduledoc false
use Ecto.Type
def type, do: :string
def cast(false), do: {:ok, nil}
def cast(public_id) when is_binary(public_id), do: {:ok, public_id}
def cast(_value), do: :error
def load(nil), do: {:ok, false}
def load(public_id) when is_binary(public_id), do: {:ok, public_id}
def dump(public_id), do: cast(public_id)
end
+44
View File
@@ -0,0 +1,44 @@
defmodule Dexi.GridsCallData do
@moduledoc "Stores short-lived transaction-signing requests for GRIDS wallets."
use Agent
@expiry_ms 2 * 60 * 1000
def start_link(initial_value \\ []) do
Agent.start_link(fn -> initial_value end, name: __MODULE__)
end
def stash(data) do
message_id = Ecto.UUID.generate()
expires_at = System.monotonic_time(:millisecond) + @expiry_ms
Agent.update(__MODULE__, &[{message_id, data, expires_at} | &1])
{:ok, message_id}
end
def get(message_id) do
current_time = System.monotonic_time(:millisecond)
case Agent.get(__MODULE__, & &1)
|> Enum.find(fn
{^message_id, _data, expires_at} -> expires_at > current_time
_entry -> false
end) do
nil -> {:error, :not_found}
{_message_id, data, _expires_at} -> {:ok, data}
end
end
def remove(message_id) do
current_time = System.monotonic_time(:millisecond)
Agent.update(__MODULE__, fn entries ->
Enum.reject(entries, fn
{^message_id, _data, _expires_at} -> true
{_other_id, _data, expires_at} -> expires_at <= current_time
end)
end)
end
end
+156
View File
@@ -0,0 +1,156 @@
defmodule Dexi.GridsLoginHandler do
@moduledoc "Maintains short-lived GRIDS login challenges by browser session."
use GenServer
alias Dexi.LoginHandler.MessageData
@name __MODULE__
def start_link(_opts), do: GenServer.start_link(__MODULE__, %{}, name: @name)
def get_signature_url(session_id),
do: GenServer.call(@name, {:get_signature_url, session_id})
def validate_message_id(message_id),
do: GenServer.call(@name, {:validate_message_id, message_id})
def write_pubkey(message_id, pubkey),
do: GenServer.call(@name, {:write_pubkey, message_id, pubkey})
def consume_pubkey(session_id, message_id),
do: GenServer.call(@name, {:consume_pubkey, session_id, message_id})
def cleanout_session_data(session_id),
do: GenServer.cast(@name, {:cleanout_session_data, session_id})
@impl true
def init(_opts) do
schedule_cleanup()
{:ok, %{sessions: %{}, messages: %{}}}
end
@impl true
def handle_call({:get_signature_url, session_id}, _from, state) do
session_messages = Map.get(state.sessions, session_id, [])
current_time = now()
case List.last(session_messages) do
%MessageData{refresh_at: refresh_at} = message when refresh_at > current_time ->
{:reply, message.signature_url, state}
_expired_or_missing ->
message = MessageData.new()
new_state = %{
sessions: Map.put(state.sessions, session_id, session_messages ++ [message]),
messages: Map.put(state.messages, message.message_id, session_id)
}
{:reply, message.signature_url, new_state}
end
end
def handle_call({:validate_message_id, message_id}, _from, state) do
result =
with session_id when is_binary(session_id) <- Map.get(state.messages, message_id),
%MessageData{} = message <- find_valid_message(state, session_id, message_id) do
{:ok, message.message_id}
else
_not_found -> {:error, :message_id_invalid}
end
{:reply, result, state}
end
def handle_call({:write_pubkey, message_id, pubkey}, _from, state) do
case Map.get(state.messages, message_id) do
nil ->
{:reply, {:error, :message_id_invalid}, state}
session_id ->
session_messages = Map.fetch!(state.sessions, session_id)
case Enum.find(session_messages, &(&1.message_id == message_id and &1.expires_at > now())) do
nil ->
{:reply, {:error, :message_id_invalid}, state}
message ->
completed = %{message | pubkey: pubkey}
message_ids = Enum.map(session_messages, & &1.message_id)
messages = Map.drop(state.messages, message_ids)
sessions = Map.put(state.sessions, session_id, [completed])
Phoenix.PubSub.broadcast(Dexi.PubSub, session_id, {:pubkey_added, message_id})
{:reply, :ok, %{state | sessions: sessions, messages: messages}}
end
end
end
def handle_call({:consume_pubkey, session_id, message_id}, _from, state) do
session_messages = Map.get(state.sessions, session_id, [])
result =
case Enum.find(session_messages, &(&1.message_id == message_id)) do
%MessageData{pubkey: pubkey} when is_binary(pubkey) -> {:ok, pubkey}
_missing -> {:error, :login_not_completed}
end
message_ids = Enum.map(session_messages, & &1.message_id)
new_state = %{
sessions: Map.delete(state.sessions, session_id),
messages: Map.drop(state.messages, message_ids)
}
{:reply, result, new_state}
end
@impl true
def handle_cast({:cleanout_session_data, session_id}, state) do
session_messages = Map.get(state.sessions, session_id, [])
message_ids = Enum.map(session_messages, & &1.message_id)
{:noreply,
%{
sessions: Map.delete(state.sessions, session_id),
messages: Map.drop(state.messages, message_ids)
}}
end
@impl true
def handle_info(:cleanup, state) do
schedule_cleanup()
{sessions, messages} =
Enum.reduce(state.sessions, {%{}, state.messages}, fn {session_id, session_messages},
{sessions, messages} ->
active = Enum.reject(session_messages, &(&1.expires_at <= now() and is_nil(&1.pubkey)))
removed_ids =
session_messages
|> Kernel.--(active)
|> Enum.map(& &1.message_id)
{Map.put(sessions, session_id, active), Map.drop(messages, removed_ids)}
end)
{:noreply, %{sessions: sessions, messages: messages}}
end
defp find_valid_message(state, session_id, message_id) do
state.sessions
|> Map.get(session_id, [])
|> Enum.find(&(&1.message_id == message_id and &1.expires_at > now()))
end
defp now, do: System.monotonic_time(:millisecond)
defp schedule_cleanup do
Process.send_after(self(), :cleanup, cleanup_interval())
end
defp cleanup_interval do
Application.fetch_env!(:dexi, :login_url_refresh_interval_in_milliseconds)
end
end
+20
View File
@@ -0,0 +1,20 @@
defmodule Dexi.LoginHandler.MessageData do
@moduledoc false
alias Dexi.Grids
defstruct [:message_id, :signature_url, :refresh_at, :expires_at, pubkey: nil]
def new do
message_id = Ecto.UUID.generate()
now = System.monotonic_time(:millisecond)
refresh_interval = Application.fetch_env!(:dexi, :login_url_refresh_interval_in_milliseconds)
%__MODULE__{
message_id: message_id,
signature_url: Grids.generate_sign_grids_url(message_id),
refresh_at: now + refresh_interval,
expires_at: now + refresh_interval * 2
}
end
end
+3
View File
@@ -0,0 +1,3 @@
defmodule Dexi.Mailer do
use Swoosh.Mailer, otp_app: :dexi
end
+56
View File
@@ -0,0 +1,56 @@
defmodule Dexi.Release do
@moduledoc false
@app :dexi
def migrate do
load_app()
for repo <- repos() do
{:ok, _function_result, _apps} =
Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def admin(operation, pubkey) do
start_app()
result =
with {:ok, parsed_operation, parsed_pubkey} <-
Dexi.CLI.Arguments.parse_admin([operation, pubkey]) do
Dexi.Accounts.AdminCommand.run(parsed_operation, parsed_pubkey)
end
print_result(result)
end
def test_users(option, count) do
start_app()
result =
with {:ok, parsed_count} <- Dexi.CLI.Arguments.parse_count([option, count]),
{:ok, users} <- Dexi.Accounts.TestUsers.create(parsed_count) do
{:ok, "Created #{length(users)} test users"}
end
print_result(result)
end
defp repos, do: Application.fetch_env!(@app, :ecto_repos)
defp load_app do
Application.load(@app)
end
defp start_app do
load_app()
{:ok, _apps} = Application.ensure_all_started(@app)
end
defp print_result({:ok, message}), do: IO.puts(message)
defp print_result({:error, message}) do
IO.puts(:stderr, message)
System.halt(1)
end
end
+5
View File
@@ -0,0 +1,5 @@
defmodule Dexi.Repo do
use Ecto.Repo,
otp_app: :dexi,
adapter: Ecto.Adapters.Postgres
end
+114
View File
@@ -0,0 +1,114 @@
defmodule DexiWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use DexiWeb, :controller
use DexiWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
use Gettext, backend: DexiWeb.Gettext
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
# Translation
use Gettext, backend: DexiWeb.Gettext
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
import DexiWeb.CoreComponents
# Common modules used in templates
alias Phoenix.LiveView.JS
alias DexiWeb.Layouts
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: DexiWeb.Endpoint,
router: DexiWeb.Router,
statics: DexiWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
@@ -0,0 +1,184 @@
defmodule DexiWeb.AccountComponents do
@moduledoc false
use DexiWeb, :html
alias Dexi.Accounts
attr :user, :any, required: true
def account_preview(assigns) do
~H"""
<section
id="account-preview"
class={[
"space-y-6"
]}
>
<div class={["grid gap-5 sm:grid-cols-2"]}>
<.detail label="Public key" value={@user.pubkey} id="account-pubkey" wide monospace />
<.detail label="Name" value={@user.name || "Not provided"} id="account-name" />
<div>
<.detail label="Email" value={@user.email || "Not provided"} id="account-email" />
<p
:if={Accounts.email_confirmed?(@user)}
id="account-email-confirmed"
class={["mt-1 text-xs font-semibold text-positive"]}
>
Email confirmed
</p>
</div>
<.detail label="Role" value={@user.role} id="account-role" capitalize />
</div>
<button
id="edit-account"
type="button"
phx-click="edit-profile"
class={[
"inline-flex items-center justify-center rounded-full bg-electric-cyan px-5 py-2.5 text-sm font-semibold text-midnight shadow-sm transition hover:bg-bright-cyan focus:outline-none focus:ring-2 focus:ring-soft-violet focus:ring-offset-2"
]}
>
Edit account
</button>
</section>
"""
end
attr :form, :any, required: true
def profile_editor(assigns) do
~H"""
<section id="account-editor">
<button
id="cancel-account-edit"
type="button"
phx-click="cancel-edit"
class={[
"mb-5 inline-flex items-center justify-center rounded-full border border-muted-steel bg-deep-slate px-5 py-2.5 text-sm font-semibold text-cool-white transition hover:bg-slate-blue focus:outline-none focus:ring-2 focus:ring-soft-violet focus:ring-offset-2"
]}
>
Cancel editing
</button>
<.form
for={@form}
id="profile-form"
phx-change="validate"
phx-submit="save"
class={["space-y-5"]}
>
<.input
field={@form[:email]}
type="email"
label="Email (optional, we may reach out to you)"
/>
<.input field={@form[:name]} type="text" label="Name (optional)" />
<button
id="save-profile"
type="submit"
class={[
"w-full rounded-xl bg-electric-cyan px-5 py-3 font-bold text-midnight transition hover:-translate-y-0.5 hover:bg-bright-cyan phx-submit-loading:opacity-60"
]}
>
Save account
</button>
</.form>
</section>
"""
end
attr :user, :any, required: true
attr :form, :any, required: true
def email_confirmation_panel(assigns) do
~H"""
<section
:if={is_binary(@user.email) and !Accounts.email_confirmed?(@user)}
id="email-confirmation"
class={["mt-8 border-t border-muted-steel pt-6"]}
>
<p class={["text-sm font-bold"]}>Confirm {@user.email}</p>
<p class={["mt-1 text-sm text-blue-grey"]}>
Enter the six-digit code sent to your email. Codes expire after 15 minutes.
</p>
<button
:if={!Accounts.email_confirmation_active?(@user)}
id="send-confirmation"
type="button"
phx-click="send-confirmation"
class={[
"mt-5 inline-flex items-center justify-center rounded-full bg-electric-cyan px-5 py-2.5 text-sm font-semibold text-midnight shadow-sm transition hover:bg-bright-cyan focus:outline-none focus:ring-2 focus:ring-soft-violet focus:ring-offset-2"
]}
>
Send confirmation code
</button>
<.form
:if={Accounts.email_confirmation_active?(@user)}
for={@form}
id="email-confirmation-form"
phx-submit="confirm-email"
class={["mt-4 space-y-4"]}
>
<.input
field={@form[:code]}
type="text"
label="Confirmation code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
required
/>
<div class={["flex flex-col gap-3 sm:flex-row"]}>
<button
id="confirm-email"
type="submit"
class={[
"flex-1 rounded-xl bg-electric-cyan px-5 py-3 font-bold text-midnight transition hover:bg-bright-cyan"
]}
>
Confirm email
</button>
<button
id="resend-confirmation"
type="button"
phx-click="send-confirmation"
class={[
"flex-1 rounded-xl border border-muted-steel px-5 py-3 font-bold transition hover:border-bright-cyan"
]}
>
Resend code
</button>
</div>
</.form>
</section>
"""
end
attr :label, :string, required: true
attr :value, :any, required: true
attr :id, :string, required: true
attr :wide, :boolean, default: false
attr :monospace, :boolean, default: false
attr :capitalize, :boolean, default: false
defp detail(assigns) do
~H"""
<div class={[@wide && "sm:col-span-2"]}>
<p class={["text-xs font-bold uppercase tracking-[0.18em] text-blue-grey"]}>{@label}</p>
<p
class={[
"mt-1.5 break-all text-base font-semibold text-cool-white",
@monospace && "font-mono text-sm",
@capitalize && "capitalize"
]}
id={@id}
>
{@value}
</p>
</div>
"""
end
end
+508
View File
@@ -0,0 +1,508 @@
defmodule DexiWeb.CoreComponents do
@moduledoc """
Provides core UI components.
At first glance, this module may seem daunting, but its goal is to provide
core building blocks for your application, such as tables, forms, and
inputs. The components consist mostly of markup and are well-documented
with doc strings and declarative assigns. You may customize and style
them in any way you want, based on your application growth and needs.
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
and themes. Here are useful references:
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
started and see the available components.
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
we build on. You will use it for layout, sizing, flexbox, grid, and
spacing.
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
* [Phoenix.Component](https://phoenix-live-view.hexdocs.pm/Phoenix.Component.html) -
the component system used by Phoenix. Some components, such as `<.link>`
and `<.form>`, are defined there.
"""
use Phoenix.Component
use Gettext, backend: DexiWeb.Gettext
alias Phoenix.LiveView.JS
@doc """
Renders flash notices.
## Examples
<.flash kind={:info} flash={@flash} />
<.flash
id="welcome-back"
kind={:info}
phx-mounted={show("#welcome-back") |> JS.remove_attribute("hidden")}
hidden
>
Welcome Back!
</.flash>
"""
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="fixed right-4 top-4 z-50 flex flex-col gap-3"
{@rest}
>
<div class={[
"w-80 max-w-[calc(100vw-2rem)] rounded-2xl border bg-slate-blue p-4 text-cool-white shadow-2xl sm:w-96",
@kind == :info && "border-electric-cyan",
@kind == :error && "border-negative"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc """
Renders a button with navigation support.
## Examples
<.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `<select>` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information. Unsupported types, such as radio, are best
written directly in your templates.
## Examples
```heex
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
```
## Select type
When using `type="select"`, you must pass the `options` and optionally
a `value` to mark which option should be preselected.
```heex
<.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
```
For more information on what kind of data can be passed to `options` see
[`options_for_select`](https://phoenix-html.hexdocs.pm/Phoenix.HTML.Form.html#options_for_select/2).
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week hidden)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="mb-2">
<label for={@id}>
<input
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={
@class ||
"size-4 rounded border-muted-steel bg-deep-slate text-electric-cyan focus:ring-electric-cyan"
}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "border-negative")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "border-negative")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "border-negative")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-negative">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc """
Renders a header with title.
"""
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-blue-grey">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc """
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc """
Renders a data list.
## Examples
<.list>
<:item title="Title">{@post.title}</:item>
<:item title="Views">{@post.views}</:item>
</.list>
"""
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).
Heroicons come in three styles outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
You can customize the size and colors of the icons by setting
width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled within
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
## Examples
<.icon name="hero-x-mark" />
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
"""
attr :name, :string, required: true
attr :class, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# However the error messages in our forms and APIs are generated
# dynamically, so we need to translate them by calling Gettext
# with our gettext backend as first argument. Translations are
# available in the errors.po file (as we use the "errors" domain).
if count = opts[:count] do
Gettext.dngettext(DexiWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(DexiWeb.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
+188
View File
@@ -0,0 +1,188 @@
defmodule DexiWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use DexiWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class={[
"border-b border-muted-steel bg-deep-slate/95 px-5 backdrop-blur"
]}>
<nav
class={["mx-auto flex h-16 max-w-6xl items-center justify-between"]}
aria-label="Main navigation"
>
<.link
navigate={~p"/"}
class={["text-lg font-black tracking-[-0.04em] text-cool-white"]}
>
Dexi
</.link>
<div class={["flex items-center gap-2"]}>
<%= if @current_scope && @current_scope.user do %>
<.link
:if={@current_scope.user.role == :admin}
navigate={~p"/admin/users"}
class={[
"rounded-full px-4 py-2 text-sm font-semibold text-cool-white transition hover:bg-soft-violet/15"
]}
>
Users
</.link>
<.link
navigate={~p"/account"}
class={[
"rounded-full px-4 py-2 text-sm font-semibold text-cool-white transition hover:bg-soft-violet/15"
]}
>
Account
</.link>
<.link
href={~p"/logout"}
method="delete"
class={[
"rounded-full border border-muted-steel px-4 py-2 text-sm font-semibold text-cool-white transition hover:border-muted-steel hover:bg-electric-cyan hover:text-midnight"
]}
>
Log out
</.link>
<% else %>
<.link
navigate={~p"/login"}
class={[
"rounded-full bg-electric-cyan px-5 py-2 text-sm font-bold text-midnight transition hover:-translate-y-0.5 hover:bg-bright-cyan"
]}
>
Log in
</.link>
<% end %>
</div>
</nav>
</header>
<main class={[
"min-h-[calc(100vh-4rem)] bg-midnight text-cool-white"
]}>
{render_slot(@inner_block)}
</main>
<.flash_group flash={@flash} />
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={
show(".phx-client-error #client-error")
|> JS.remove_attribute("hidden", to: ".phx-client-error #client-error")
}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={
show(".phx-server-error #server-error")
|> JS.remove_attribute("hidden", to: ".phx-server-error #server-error")
}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.
See <head> in root.html.heex which applies the theme before page load.
"""
def theme_toggle(assigns) do
~H"""
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 [[data-theme-source=system]_&]:!left-0 transition-[left]" />
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="system"
>
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="light"
>
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="dark"
>
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
</div>
"""
end
end
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="Dexi" suffix=" · Dexi" phx-no-format>{assigns[:page_title]}</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
<script>
(() => {
const systemTheme = () => matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.setAttribute("data-theme", systemTheme());
document.documentElement.setAttribute("data-theme-source", "system");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.setAttribute("data-theme-source", "user");
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
if (document.documentElement.getAttribute("data-theme-source") === "system") {
document.documentElement.setAttribute("data-theme", systemTheme());
}
});
})();
</script>
</head>
<body>
{@inner_content}
</body>
</html>
@@ -0,0 +1,41 @@
defmodule DexiWeb.DeadDropController do
use DexiWeb, :controller
alias Dexi.Grids
alias Dexi.Grids.DeadDrop
alias Dexi.GridsLoginHandler
def show(conn, %{"message_id" => message_id}) do
with {:ok, message} <- GridsLoginHandler.validate_message_id(message_id),
{:ok, network_id} <- Grids.network_id(),
{:ok, dead_drop} <- Grids.create_dead_drop(%{payload: message, network_id: network_id}) do
json(conn, dead_drop_data(dead_drop))
else
_error -> send_resp(conn, :not_found, "")
end
end
def login(conn, %{"message_id" => message_id} = params) do
with {:ok, message} <- GridsLoginHandler.validate_message_id(message_id),
{:ok, dead_drop} <- Grids.create_dead_drop(Map.put(params, "payload", message)),
{:ok, %DeadDrop{} = verified_dead_drop} <-
Grids.verify_dead_drop_signature(dead_drop),
:ok <- GridsLoginHandler.write_pubkey(message_id, verified_dead_drop.public_id) do
json(conn, dead_drop_data(verified_dead_drop))
else
_error -> send_resp(conn, :unprocessable_entity, "")
end
end
defp dead_drop_data(dead_drop) do
%{
grids: dead_drop.grids,
chain: dead_drop.chain,
network_id: dead_drop.network_id,
type: dead_drop.type,
public_id: dead_drop.public_id,
payload: dead_drop.payload,
signature: dead_drop.signature
}
end
end
+24
View File
@@ -0,0 +1,24 @@
defmodule DexiWeb.ErrorHTML do
@moduledoc """
This module is invoked by your endpoint in case of errors on HTML requests.
See config/config.exs.
"""
use DexiWeb, :html
# If you want to customize your error pages,
# uncomment the embed_templates/1 call below
# and add pages to the error directory:
#
# * lib/dexi_web/controllers/error_html/404.html.heex
# * lib/dexi_web/controllers/error_html/500.html.heex
#
# embed_templates "error_html/*"
# The default is to render a plain text page based on
# the template name. For example, "404.html" becomes
# "Not Found".
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
+21
View File
@@ -0,0 +1,21 @@
defmodule DexiWeb.ErrorJSON do
@moduledoc """
This module is invoked by your endpoint in case of errors on JSON requests.
See config/config.exs.
"""
# If you want to customize a particular status code,
# you may add your own clauses, such as:
#
# def render("500.json", _assigns) do
# %{errors: %{detail: "Internal Server Error"}}
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.json" becomes
# "Not Found".
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
@@ -0,0 +1,7 @@
defmodule DexiWeb.PageController do
use DexiWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end
+10
View File
@@ -0,0 +1,10 @@
defmodule DexiWeb.PageHTML do
@moduledoc """
This module contains pages rendered by PageController.
See the `page_html` directory for all templates available.
"""
use DexiWeb, :html
embed_templates "page_html/*"
end
@@ -0,0 +1,21 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<section class={["flex min-h-[calc(100vh-4rem)] items-center justify-center px-6 text-center"]}>
<div class={["max-w-3xl"]}>
<p class={[
"text-2xl font-semibold tracking-tight text-blue-grey sm:text-4xl"
]}>
Hi, I am Dexi - the DEX interface from QPQ IaaS AG
</p>
<.link
:if={!@current_scope.user}
navigate={~p"/login"}
id="home-login"
class={[
"mt-8 inline-flex rounded-full bg-electric-cyan px-6 py-3 text-sm font-bold text-midnight transition hover:-translate-y-0.5 hover:bg-bright-cyan"
]}
>
Log in with your wallet
</.link>
</div>
</section>
</Layouts.app>
@@ -0,0 +1,34 @@
defmodule DexiWeb.SessionController do
use DexiWeb, :controller
alias Dexi.Accounts
alias Dexi.GridsLoginHandler
alias DexiWeb.UserAuth
def create(conn, %{"message_id" => message_id}) do
with {:ok, pubkey} <-
GridsLoginHandler.consume_pubkey(conn.assigns.session_id, message_id),
{:ok, user, status} <- Accounts.get_or_create_user_with_status(pubkey) do
conn = UserAuth.log_in_user(conn, user)
redirect(conn, to: signed_in_path(status))
else
_error ->
conn
|> put_flash(:error, "That login request is invalid or has expired.")
|> redirect(to: ~p"/login")
end
end
def delete(conn, _params) do
session_id = conn.assigns.session_id
GridsLoginHandler.cleanout_session_data(session_id)
conn
|> UserAuth.log_out_user()
|> redirect(to: ~p"/")
end
defp signed_in_path(:created), do: ~p"/account"
defp signed_in_path(:existing), do: ~p"/"
end
@@ -0,0 +1,85 @@
defmodule DexiWeb.SignTxController do
use DexiWeb, :controller
require Logger
alias Dexi.ChainTransactions
alias Dexi.Grids
alias Dexi.GridsCallData
def show(conn, %{"message_id" => message_id}) do
with {:ok, {_requester, pubkey, unsigned_tx}} <- GridsCallData.get(message_id),
{:ok, network_id} <- Grids.network_id(),
{:ok, dead_drop} <-
Grids.create_dead_drop(%{
payload: unsigned_tx,
public_id: pubkey,
network_id: network_id,
type: "tx"
}) do
json(conn, dead_drop_data(dead_drop))
else
_error -> not_found(conn)
end
end
def sign(
conn,
%{"message_id" => message_id, "public_id" => pubkey, "payload" => signed_tx} = params
) do
case GridsCallData.get(message_id) do
{:ok, {requester, ^pubkey, unsigned_tx}} ->
submit_signed_tx(conn, requester, pubkey, unsigned_tx, signed_tx, message_id, params)
{:ok, {_requester, _other_pubkey, _unsigned_tx}} ->
Logger.info("Rejected transaction signature with mismatched public key")
not_found(conn)
{:error, :not_found} ->
not_found(conn)
end
end
def sign(conn, _params), do: not_found(conn)
defp submit_signed_tx(
conn,
requester,
pubkey,
unsigned_tx,
signed_tx,
message_id,
params
) do
with {:ok, dead_drop} <- Grids.create_dead_drop(params),
:ok <- ChainTransactions.verify_signed_tx(pubkey, unsigned_tx, signed_tx),
{:ok, tx_hash} <- ChainTransactions.post_tx(signed_tx),
:ok <- GridsCallData.remove(message_id) do
send(requester, {:tx_success, tx_hash})
json(conn, dead_drop_data(dead_drop))
else
error ->
Logger.info("Transaction signing failed: #{inspect(error)}")
send(requester, :tx_failed)
not_found(conn)
end
end
defp not_found(conn) do
conn
|> put_status(:not_found)
|> json(%{error: "not_found"})
end
defp dead_drop_data(dead_drop) do
%{
grids: dead_drop.grids,
chain: dead_drop.chain,
network_id: dead_drop.network_id,
type: dead_drop.type,
public_id: dead_drop.public_id,
payload: dead_drop.payload,
signature: dead_drop.signature
}
end
end
+55
View File
@@ -0,0 +1,55 @@
defmodule DexiWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :dexi
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_dexi_key",
signing_salt: "6pb3SmeE",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# When code reloading is disabled (e.g., in production),
# the `gzip` option is enabled to serve compressed
# static files generated by running `phx.digest`.
plug Plug.Static,
at: "/",
from: :dexi,
gzip: not code_reloading?,
only: DexiWeb.static_paths(),
raise_on_missing_only: code_reloading?
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :dexi
end
plug Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug DexiWeb.Router
end
+25
View File
@@ -0,0 +1,25 @@
defmodule DexiWeb.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
By using [Gettext](https://gettext.hexdocs.pm), your module compiles translations
that you can use in your application. To use this Gettext backend module,
call `use Gettext` and pass it as an option:
use Gettext, backend: DexiWeb.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://gettext.hexdocs.pm) for detailed usage.
"""
use Gettext.Backend, otp_app: :dexi
end
@@ -0,0 +1,40 @@
defmodule DexiWeb.Admin.UserListParams do
@moduledoc false
alias Dexi.Accounts
defstruct search: "", role: :all, page: 1
def from_query(params) do
%__MODULE__{
search: Map.get(params, "search", Map.get(params, "q", "")),
role: Accounts.normalize_search_role(Map.get(params, "role", "all")),
page: parse_page(Map.get(params, "page"))
}
end
def from_form(params) do
%__MODULE__{
search: Map.get(params, "query", ""),
role: Accounts.normalize_search_role(Map.get(params, "role", "all")),
page: 1
}
end
def query(%__MODULE__{} = filter, page \\ nil) do
%{
page: page || filter.page,
search: filter.search,
role: Atom.to_string(filter.role)
}
end
defp parse_page(nil), do: 1
defp parse_page(value) do
case Integer.parse(value) do
{page, ""} when page > 0 -> page
_error -> 1
end
end
end
+378
View File
@@ -0,0 +1,378 @@
defmodule DexiWeb.Admin.UsersLive do
use DexiWeb, :live_view
alias Dexi.Accounts
alias DexiWeb.Admin.UserListParams
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Users")
|> assign(:search_term, "")
|> assign(:role_filter, :all)
|> assign(:current_page, 1)
|> assign(:total_pages, 1)
|> assign(:total_entries, 0)
|> assign(:visible_pages, [1])
|> assign(:editing_user, nil)
|> assign(:edit_form, nil)
|> assign_search_form()
|> stream_configure(:users, dom_id: &"user-#{&1.pubkey}")
|> stream(:users, [])}
end
@impl true
def handle_params(params, _uri, socket) do
filter = UserListParams.from_query(params)
page = Accounts.search_page(filter.search, filter.role, page: filter.page)
socket =
socket
|> assign(:search_term, filter.search)
|> assign(:role_filter, filter.role)
|> assign_search_form()
|> assign_page(page)
socket =
if filter.page == page.page do
socket
else
push_patch(socket,
to: users_path(filter.search, filter.role, page.page),
replace: true
)
end
{:noreply, socket}
end
@impl true
def handle_event("search", %{"search" => params}, socket) do
filter = UserListParams.from_form(params)
{:noreply,
push_patch(socket,
to: users_path(filter.search, filter.role, filter.page),
replace: true
)}
end
def handle_event("edit-user", %{"pubkey" => pubkey}, socket) do
case Accounts.get_user_by_pubkey(pubkey) do
nil -> {:noreply, put_flash(socket, :error, "User no longer exists.")}
user -> {:noreply, open_editor(socket, user)}
end
end
def handle_event("cancel-edit", _params, socket) do
{:noreply, close_editor(socket)}
end
def handle_event("save-user", %{"user" => params}, socket) do
actor = socket.assigns.current_scope.user
case Accounts.update_user_details(actor, socket.assigns.editing_user, params) do
{:ok, _user} ->
{:noreply,
socket
|> close_editor()
|> reload_users()
|> put_flash(:info, "User updated.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :edit_form, to_form(changeset))}
{:error, :unauthorized} ->
{:noreply,
socket
|> close_editor()
|> put_flash(:error, "Your admin access has changed. Reload and sign in again.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "The user could not be updated.")}
end
end
def handle_event("set-role", %{"pubkey" => pubkey, "role" => role}, socket) do
actor = socket.assigns.current_scope.user
with target when not is_nil(target) <- Accounts.get_user_by_pubkey(pubkey),
{:ok, _user} <- Accounts.set_role(actor, target, parse_role(role)) do
{:noreply, reload_users(socket)}
else
{:error, :self_demotion} ->
{:noreply, put_flash(socket, :error, "You cannot remove your own admin access here.")}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, "Your admin access has changed.")}
_error ->
{:noreply, put_flash(socket, :error, "The user role could not be changed.")}
end
end
defp parse_role("admin"), do: :admin
defp parse_role(_role), do: :user
defp open_editor(socket, user) do
socket
|> assign(:editing_user, user)
|> assign(:edit_form, to_form(Accounts.change_profile(user)))
end
defp close_editor(socket) do
socket
|> assign(:editing_user, nil)
|> assign(:edit_form, nil)
end
defp reload_users(socket) do
page =
Accounts.search_page(socket.assigns.search_term, socket.assigns.role_filter,
page: socket.assigns.current_page
)
assign_page(socket, page)
end
defp assign_page(socket, page) do
socket
|> assign(:current_page, page.page)
|> assign(:total_pages, page.total_pages)
|> assign(:total_entries, page.total_entries)
|> assign(:visible_pages, visible_pages(page.page, page.total_pages))
|> stream(:users, page.entries, reset: true)
end
defp visible_pages(current_page, total_pages) do
first = max(current_page - 2, 1)
last = min(current_page + 2, total_pages)
Enum.to_list(first..last)
end
defp assign_search_form(socket) do
params = %{
"query" => socket.assigns.search_term,
"role" => Atom.to_string(socket.assigns.role_filter)
}
assign(socket, :search_form, to_form(params, as: :search))
end
defp users_path(term, role, page) do
filter = %UserListParams{search: term, role: role, page: page}
~p"/admin/users?#{UserListParams.query(filter)}"
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<main id="admin-users-page" class="mx-auto w-full max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
<header class="mb-8">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-electric-cyan">
Administration
</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-cool-white">Users</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-blue-grey">
Search users, update profile details, and manage administrative access.
</p>
</header>
<.form
for={@search_form}
id="user-search-form"
phx-change="search"
class="mb-6 flex max-w-3xl items-end gap-3"
>
<div class="min-w-0 flex-1">
<.input
field={@search_form[:query]}
type="search"
label="Search users"
placeholder="Public key, name, or email"
phx-debounce="250"
/>
</div>
<div class="w-36 shrink-0">
<.input
field={@search_form[:role]}
type="select"
label="Role"
options={[{"All", "all"}, {"Users", "user"}, {"Admins", "admin"}]}
/>
</div>
</.form>
<div class="mb-4 text-sm text-blue-grey">
{@total_entries} {if(@total_entries == 1, do: "user", else: "users")}
</div>
<section id="users" phx-update="stream" class="grid gap-4 md:grid-cols-2">
<div
id="users-empty"
class="hidden only:block rounded-2xl border border-dashed border-muted-steel bg-deep-slate p-10 text-center text-sm text-blue-grey md:col-span-2"
>
No users match this search.
</div>
<article
:for={{dom_id, user} <- @streams.users}
id={dom_id}
class="overflow-hidden rounded-2xl border border-muted-steel bg-slate-blue shadow-sm transition hover:-translate-y-0.5 hover:border-electric-cyan/60"
>
<button
id={"edit-user-#{user.pubkey}"}
type="button"
phx-click="edit-user"
phx-value-pubkey={user.pubkey}
class="block w-full p-5 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-electric-cyan"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="truncate font-semibold text-cool-white">{user.name || "Unnamed user"}</p>
<p class="mt-1 truncate text-sm text-blue-grey">{user.email || "No email"}</p>
</div>
<span class="rounded-full border border-muted-steel px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-electric-cyan">
{user.role}
</span>
</div>
<p class="mt-4 truncate font-mono text-xs text-blue-grey">{user.pubkey}</p>
</button>
<div class="flex gap-2 border-t border-muted-steel px-5 py-3">
<button
id={"make-user-#{user.pubkey}"}
type="button"
phx-click="set-role"
phx-value-pubkey={user.pubkey}
phx-value-role="user"
disabled={user.role == :user || user.pubkey == @current_scope.user.pubkey}
class="rounded-full border border-muted-steel px-3 py-1.5 text-xs font-semibold text-blue-grey transition hover:border-bright-cyan hover:text-cool-white disabled:cursor-not-allowed disabled:opacity-40"
>
Make user
</button>
<button
id={"make-admin-#{user.pubkey}"}
type="button"
phx-click="set-role"
phx-value-pubkey={user.pubkey}
phx-value-role="admin"
disabled={user.role == :admin}
class="rounded-full bg-electric-cyan px-3 py-1.5 text-xs font-semibold text-midnight-navy transition hover:bg-bright-cyan disabled:cursor-not-allowed disabled:opacity-40"
>
Make admin
</button>
</div>
</article>
</section>
<nav
:if={@total_pages > 1}
id="users-pagination"
aria-label="User pages"
class="mt-8 flex flex-wrap items-center justify-center gap-2"
>
<.link
:if={@current_page > 1}
id="previous-users-page"
patch={users_path(@search_term, @role_filter, @current_page - 1)}
class="rounded-full border border-muted-steel bg-deep-slate px-4 py-2 text-sm font-semibold text-cool-white transition hover:border-electric-cyan hover:text-bright-cyan"
>
Previous
</.link>
<.link
:for={page_number <- @visible_pages}
id={"users-page-#{page_number}"}
patch={users_path(@search_term, @role_filter, page_number)}
aria-current={if(page_number == @current_page, do: "page", else: nil)}
class={[
"grid size-10 place-items-center rounded-full border text-sm font-semibold transition",
if(page_number == @current_page,
do: "border-electric-cyan bg-electric-cyan text-midnight-navy",
else:
"border-muted-steel bg-deep-slate text-cool-white hover:border-electric-cyan hover:text-bright-cyan"
)
]}
>
{page_number}
</.link>
<.link
:if={@current_page < @total_pages}
id="next-users-page"
patch={users_path(@search_term, @role_filter, @current_page + 1)}
class="rounded-full border border-muted-steel bg-deep-slate px-4 py-2 text-sm font-semibold text-cool-white transition hover:border-electric-cyan hover:text-bright-cyan"
>
Next
</.link>
</nav>
<div
:if={@editing_user}
id="edit-user-modal"
role="dialog"
aria-modal="true"
aria-labelledby="edit-user-title"
phx-window-keydown="cancel-edit"
phx-key="escape"
class="fixed inset-0 z-50 grid place-items-center bg-midnight-navy/85 p-4 backdrop-blur-sm"
>
<.focus_wrap
id="edit-user-dialog"
phx-click-away="cancel-edit"
class="w-full max-w-lg rounded-3xl border border-muted-steel bg-slate-blue p-6 shadow-2xl"
>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-electric-cyan">
Edit user
</p>
<h2 id="edit-user-title" class="mt-2 text-xl font-semibold text-cool-white">
{@editing_user.name || "Unnamed user"}
</h2>
</div>
<button
id="close-user-editor"
type="button"
phx-click="cancel-edit"
aria-label="Close user editor"
class="rounded-full p-2 text-blue-grey transition hover:bg-deep-slate hover:text-cool-white"
>
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
<p class="mt-4 break-all font-mono text-xs text-blue-grey">{@editing_user.pubkey}</p>
<.form for={@edit_form} id="edit-user-form" phx-submit="save-user" class="mt-6 space-y-4">
<.input field={@edit_form[:name]} type="text" label="Name" />
<.input field={@edit_form[:email]} type="email" label="Email" />
<div class="flex justify-end gap-3 pt-2">
<button
id="cancel-user-edit"
type="button"
phx-click="cancel-edit"
class="rounded-full border border-muted-steel px-4 py-2 text-sm font-semibold text-cool-white transition hover:border-bright-cyan"
>
Cancel
</button>
<button
id="save-user"
type="submit"
class="rounded-full bg-electric-cyan px-4 py-2 text-sm font-semibold text-midnight-navy transition hover:bg-bright-cyan"
>
Save user
</button>
</div>
</.form>
</.focus_wrap>
</div>
</main>
</Layouts.app>
"""
end
end
+135
View File
@@ -0,0 +1,135 @@
defmodule DexiWeb.LoginLive do
use DexiWeb, :live_view
alias Dexi.GridsLoginHandler
@impl true
def mount(_params, %{"session_id" => session_id}, socket) do
if connected?(socket), do: Phoenix.PubSub.subscribe(Dexi.PubSub, session_id)
grids_url = GridsLoginHandler.get_signature_url(session_id)
socket =
socket
|> assign(:page_title, "Log in")
|> assign(:session_id, session_id)
|> assign_grids_url(grids_url)
|> schedule_refresh()
{:ok, socket}
end
@impl true
def handle_info({:pubkey_added, message_id}, socket) do
{:noreply, redirect(socket, to: ~p"/session/#{message_id}")}
end
def handle_info(:refresh_grids_url, socket) do
grids_url = GridsLoginHandler.get_signature_url(socket.assigns.session_id)
{:noreply,
socket
|> assign_grids_url(grids_url)
|> schedule_refresh()}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<section class={["mx-auto flex max-w-3xl flex-col items-center px-5 py-16 text-center sm:py-24"]}>
<p class={[
"text-xs font-black uppercase tracking-[0.28em] text-blue-grey"
]}>
Wallet authentication
</p>
<h1 class={["mt-4 text-4xl font-black tracking-[-0.05em] sm:text-6xl"]}>
Log in to Dexi
</h1>
<p class={["mt-5 max-w-xl text-base leading-7 text-blue-grey"]}>
Sign this one-time message with the account key you want to use. A new key creates a new user account.
</p>
<div
id="grids-login"
class={[
"mt-10 w-full rounded-3xl border border-muted-steel bg-slate-blue p-5 shadow-cyan-glow backdrop-blur sm:p-8"
]}
>
<div
id="grids-qr-code"
class={["mx-auto mb-7 w-fit rounded-2xl bg-cool-white p-4 shadow-sm"]}
>
{raw(@qr_code)}
</div>
<label
for="grids-url"
class={["block text-left text-xs font-black uppercase tracking-[0.18em]"]}
>
GRIDS URL
</label>
<input
id="grids-url"
name="grids-url"
type="text"
readonly
value={@grids_url}
class={[
"mt-3 w-full rounded-xl border border-muted-steel bg-deep-slate px-4 py-3 font-mono text-xs text-cool-white outline-none selection:bg-soft-violet"
]}
/>
<button
id="copy-grids-url"
type="button"
phx-hook="CopyToClipboard"
phx-update="ignore"
data-copy-target="#grids-url"
class={[
"mt-4 inline-flex w-full items-center justify-center rounded-xl bg-electric-cyan px-5 py-3 font-bold text-midnight transition hover:-translate-y-0.5 hover:bg-bright-cyan"
]}
>
Copy GRIDS URL
</button>
</div>
<ol class={[
"mt-9 grid w-full gap-3 text-left text-sm leading-6 text-blue-grey sm:grid-cols-3"
]}>
<li class={["rounded-2xl border border-muted-steel p-4"]}>
1. Open your wallet and select an account.
</li>
<li class={["rounded-2xl border border-muted-steel p-4"]}>
2. Open GRIDS URL and paste the code if needed.
</li>
<li class={["rounded-2xl border border-muted-steel p-4"]}>
3. Approve the message signature.
</li>
</ol>
</section>
</Layouts.app>
"""
end
defp schedule_refresh(socket) do
if connected?(socket) do
Process.send_after(
self(),
:refresh_grids_url,
Application.fetch_env!(:dexi, :login_url_refresh_interval_in_milliseconds)
)
end
socket
end
defp assign_grids_url(socket, grids_url) do
qr_code =
grids_url
|> EQRCode.encode()
|> EQRCode.svg(%{color: "var(--color-electric-cyan)", width: 256})
socket
|> assign(:grids_url, grids_url)
|> assign(:qr_code, qr_code)
end
end
+133
View File
@@ -0,0 +1,133 @@
defmodule DexiWeb.ProfileLive do
use DexiWeb, :live_view
alias Dexi.Accounts
alias Dexi.Accounts.Scope
alias DexiWeb.AccountComponents
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Account")
|> assign(:editing?, false)
|> assign_account(socket.assigns.current_scope.user)}
end
@impl true
def handle_event("edit-profile", _params, socket) do
{:noreply, assign(socket, :editing?, true)}
end
def handle_event("cancel-edit", _params, socket) do
{:noreply,
socket
|> assign(:editing?, false)
|> assign(:form, to_form(Accounts.change_profile(socket.assigns.current_scope.user)))}
end
def handle_event("validate", %{"user" => params}, socket) do
form =
socket.assigns.current_scope.user
|> Accounts.change_profile(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, :form, form)}
end
def handle_event("save", %{"user" => params}, socket) do
case Accounts.update_profile(socket.assigns.current_scope.user, params) do
{:ok, user} ->
{:noreply,
socket
|> assign_account(user)
|> assign(:editing?, false)
|> put_flash(:info, "Account saved.")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
def handle_event("send-confirmation", _params, socket) do
user = socket.assigns.current_scope.user
{:noreply, send_confirmation(socket, user)}
end
def handle_event("confirm-email", %{"confirmation" => %{"code" => code}}, socket) do
case Accounts.confirm_email(socket.assigns.current_scope.user, code) do
{:ok, user} ->
{:noreply,
socket
|> assign_account(user)
|> put_flash(:info, "Email confirmed.")}
{:error, :expired} ->
{:noreply, put_flash(socket, :error, "That code expired. Request a new one.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "That confirmation code is invalid.")}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<section class={["mx-auto max-w-2xl px-5 py-16 sm:py-24"]}>
<p class={["text-xs font-black uppercase tracking-[0.28em] text-blue-grey"]}>Account</p>
<h1 class={["mt-3 text-4xl font-black tracking-[-0.05em] sm:text-5xl"]}>Your details</h1>
<p class={["mt-4 text-blue-grey"]}>
Your public key is your identity. Email and name are optional.
</p>
<div class={[
"mt-8 rounded-3xl border border-muted-steel bg-slate-blue p-6 shadow-cyan-glow sm:p-8"
]}>
<AccountComponents.account_preview
:if={!@editing?}
user={@current_scope.user}
/>
<AccountComponents.profile_editor :if={@editing?} form={@form} />
<AccountComponents.email_confirmation_panel
user={@current_scope.user}
form={@confirmation_form}
/>
</div>
</section>
</Layouts.app>
"""
end
defp assign_account(socket, user) do
user =
case Accounts.clear_expired_email_confirmation(user) do
{:ok, refreshed_user} -> refreshed_user
{:error, _reason} -> user
end
socket
|> assign(:current_scope, Scope.for_user(user))
|> assign(:form, to_form(Accounts.change_profile(user)))
|> assign(:confirmation_form, to_form(%{"code" => ""}, as: :confirmation))
end
defp send_confirmation(socket, user) do
case Accounts.request_email_confirmation(user) do
{:ok, updated_user} ->
socket
|> assign_account(updated_user)
|> put_flash(:info, "Confirmation code sent.")
{:error, :rate_limited} ->
put_flash(socket, :error, "Please wait one minute before requesting another code.")
{:error, :already_confirmed} ->
put_flash(socket, :info, "That email is already confirmed.")
{:error, _reason} ->
put_flash(socket, :error, "The confirmation email could not be sent.")
end
end
end
+26
View File
@@ -0,0 +1,26 @@
defmodule DexiWeb.SessionIdPlug do
@moduledoc false
import Plug.Conn
@session_id_bytes 24
def init(opts), do: opts
def call(conn, _opts) do
case get_session(conn, :session_id) do
nil ->
session_id =
@session_id_bytes
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
conn
|> put_session(:session_id, session_id)
|> assign(:session_id, session_id)
session_id ->
assign(conn, :session_id, session_id)
end
end
end
+84
View File
@@ -0,0 +1,84 @@
defmodule DexiWeb.Router do
use DexiWeb, :router
import DexiWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {DexiWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug DexiWeb.SessionIdPlug
plug :fetch_current_scope
end
pipeline :api do
plug :accepts, ["json"]
end
pipeline :authenticated_user do
plug :require_authenticated_user
end
pipeline :admin_user do
plug :require_admin
end
scope "/", DexiWeb do
pipe_through :browser
get "/", PageController, :home
get "/session/:message_id", SessionController, :create
delete "/logout", SessionController, :delete
live_session :public, on_mount: [{DexiWeb.UserAuth, :mount_current_scope}] do
live "/login", LoginLive, :index
end
end
scope "/", DexiWeb do
pipe_through [:browser, :authenticated_user]
live_session :authenticated,
on_mount: [{DexiWeb.UserAuth, :require_authenticated}] do
live "/account", ProfileLive, :edit
end
end
scope "/admin", DexiWeb.Admin do
pipe_through [:browser, :authenticated_user, :admin_user]
live_session :admin, on_mount: [{DexiWeb.UserAuth, :require_admin}] do
live "/users", UsersLive, :index
end
end
scope "/api", DexiWeb do
pipe_through :api
get "/signature/:message_id", DeadDropController, :show
post "/signature/:message_id", DeadDropController, :login
get "/sign/:message_id", SignTxController, :show
post "/sign/:message_id", SignTxController, :sign
end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:dexi, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
import Phoenix.LiveDashboard.Router
scope "/dev" do
pipe_through :browser
live_dashboard "/dashboard", metrics: DexiWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end
+93
View File
@@ -0,0 +1,93 @@
defmodule DexiWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
# Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://telemetry-metrics.hexdocs.pm
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
# Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.start.system_time",
unit: {:native, :millisecond}
),
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.start.system_time",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.exception.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.socket_connected.duration",
unit: {:native, :millisecond}
),
sum("phoenix.socket_drain.count"),
summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_handled_in.duration",
tags: [:event],
unit: {:native, :millisecond}
),
# Database Metrics
summary("dexi.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("dexi.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("dexi.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("dexi.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("dexi.repo.query.idle_time",
unit: {:native, :millisecond},
description:
"The time the connection spent waiting before being checked out for the query"
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {DexiWeb, :count_users, []}
]
end
end
+100
View File
@@ -0,0 +1,100 @@
defmodule DexiWeb.UserAuth do
@moduledoc false
use DexiWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias Dexi.Accounts
alias Dexi.Accounts.Scope
def fetch_current_scope(conn, _opts) do
user = conn |> get_session(:user_pubkey) |> Accounts.get_user_by_pubkey()
assign(conn, :current_scope, Scope.for_user(user))
end
def log_in_user(conn, user) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
|> put_session(:user_pubkey, user.pubkey)
end
def log_out_user(conn) do
if live_socket_id = get_session(conn, :live_socket_id) do
DexiWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
def require_authenticated_user(conn, _opts) do
if conn.assigns.current_scope.user do
conn
else
conn
|> put_flash(:error, "Log in to continue.")
|> redirect(to: ~p"/login")
|> halt()
end
end
def require_admin(conn, _opts) do
case conn.assigns.current_scope.user do
%{role: :admin} ->
conn
_not_admin ->
conn
|> put_flash(:error, "You do not have access to that page.")
|> redirect(to: ~p"/")
|> halt()
end
end
def on_mount(:mount_current_scope, _params, session, socket) do
{:cont, mount_current_scope(socket, session)}
end
def on_mount(:require_authenticated, _params, session, socket) do
socket = mount_current_scope(socket, session)
if socket.assigns.current_scope.user do
{:cont, socket}
else
{:halt,
socket
|> Phoenix.LiveView.put_flash(:error, "Log in to continue.")
|> Phoenix.LiveView.redirect(to: ~p"/login")}
end
end
def on_mount(:require_admin, _params, session, socket) do
socket = mount_current_scope(socket, session)
case socket.assigns.current_scope.user do
%{role: :admin} ->
{:cont, socket}
_not_admin ->
{:halt,
socket
|> Phoenix.LiveView.put_flash(:error, "You do not have access to that page.")
|> Phoenix.LiveView.redirect(to: ~p"/")}
end
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
user = session |> Map.get("user_pubkey") |> Accounts.get_user_by_pubkey()
Scope.for_user(user)
end)
end
end
+17
View File
@@ -0,0 +1,17 @@
defmodule Mix.Tasks.Dexi.Admin do
use Mix.Task
@shortdoc "Creates or removes an administrator"
@impl Mix.Task
def run(arguments) do
Mix.Task.run("app.start")
with {:ok, operation, pubkey} <- Dexi.CLI.Arguments.parse_admin(arguments),
{:ok, message} <- Dexi.Accounts.AdminCommand.run(operation, pubkey) do
Mix.shell().info(message)
else
{:error, message} -> Mix.raise(message)
end
end
end
+17
View File
@@ -0,0 +1,17 @@
defmodule Mix.Tasks.Dexi.TestUsers do
use Mix.Task
@shortdoc "Creates test users in bulk"
@impl Mix.Task
def run(arguments) do
Mix.Task.run("app.start")
with {:ok, count} <- Dexi.CLI.Arguments.parse_count(arguments),
{:ok, users} <- Dexi.Accounts.TestUsers.create(count) do
Mix.shell().info("Created #{length(users)} test users")
else
{:error, message} -> Mix.raise(message)
end
end
end
+93
View File
@@ -0,0 +1,93 @@
defmodule :zj do
@moduledoc false
def encode(term) do
term
|> normalize_for_json(:loose)
|> Jason.encode!()
|> String.to_charlist()
end
def decode(stream) do
with {:ok, decoded} <- stream |> :unicode.characters_to_binary() |> Jason.decode() do
{:ok, to_charlist_values(decoded)}
end
end
def binary_encode(term) do
term
|> normalize_for_json(:strict)
|> Jason.encode!()
end
def binary_decode(stream) do
with {:ok, decoded} <- stream |> :unicode.characters_to_binary() |> Jason.decode() do
{:ok, replace_null(decoded)}
end
end
defp to_charlist_values(nil), do: :undefined
defp to_charlist_values(value) when is_binary(value), do: String.to_charlist(value)
defp to_charlist_values(value) when is_list(value) do
Enum.map(value, &to_charlist_values/1)
end
defp to_charlist_values(value) when is_map(value) do
Map.new(value, fn {key, item} ->
{to_charlist_values(key), to_charlist_values(item)}
end)
end
defp to_charlist_values(value), do: value
defp replace_null(nil), do: :undefined
defp replace_null(value) when is_list(value), do: Enum.map(value, &replace_null/1)
defp replace_null(value) when is_map(value) do
Map.new(value, fn {key, item} -> {key, replace_null(item)} end)
end
defp replace_null(value), do: value
defp normalize_for_json(:undefined, _mode), do: nil
defp normalize_for_json(value, _mode) when is_binary(value), do: value
defp normalize_for_json(value, _mode) when is_number(value), do: value
defp normalize_for_json(value, _mode) when value in [nil, true, false], do: value
defp normalize_for_json(value, _mode) when is_atom(value) do
Atom.to_string(value)
end
defp normalize_for_json(value, :loose) when is_list(value) do
if List.ascii_printable?(value) do
List.to_string(value)
else
Enum.map(value, &normalize_for_json(&1, :loose))
end
end
defp normalize_for_json(value, :strict) when is_list(value) do
Enum.map(value, &normalize_for_json(&1, :strict))
end
defp normalize_for_json(value, mode) when is_tuple(value) do
value
|> Tuple.to_list()
|> Enum.map(&normalize_for_json(&1, mode))
end
defp normalize_for_json(value, mode) when is_map(value) do
Map.new(value, fn {key, item} ->
{normalize_key(key), normalize_for_json(item, mode)}
end)
end
defp normalize_for_json(value, _mode) do
to_string(value)
end
defp normalize_key(key) when is_binary(key) or is_atom(key), do: key
defp normalize_key(key) when is_list(key), do: List.to_string(key)
defp normalize_key(key), do: to_string(key)
end
+101
View File
@@ -0,0 +1,101 @@
defmodule Dexi.MixProject do
use Mix.Project
def project do
[
app: :dexi,
version: "0.1.0",
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
compilers: [:phoenix_live_view] ++ Mix.compilers(),
listeners: [Phoenix.CodeReloader]
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {Dexi.Application, []},
extra_applications: [:logger, :runtime_tools, :hakuzaru]
]
end
def cli do
[
preferred_envs: [precommit: :test]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.8.8"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.2.0"},
{:lazy_html, ">= 0.1.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
{:heroicons,
github: "tailwindlabs/heroicons",
tag: "v2.2.0",
sparse: "optimized",
app: false,
compile: false,
depth: 1},
{:swoosh, "~> 1.16"},
{:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 1.0"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"},
{:hakuzaru,
git: "https://git.qpq.swiss/QPQ-AG/hakuzaru.git", ref: "9a7a2a98c4", compile: "erl -make"},
{:gmserialization,
git: "https://git.qpq.swiss/QPQ-AG/gmserialization.git", branch: "eureka", override: true},
{:ec_utils, git: "https://git.qpq.swiss/QPQ-AG/ec_utils.git"},
{:eblake2, "1.0.0"},
{:eqrcode, "~> 0.2"}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.build": ["compile", "tailwind dexi", "esbuild dexi"],
"assets.deploy": [
"tailwind dexi --minify",
"esbuild dexi --minify",
"phx.digest"
],
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
]
end
end
+53
View File
@@ -0,0 +1,53 @@
%{
"bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"},
"base58": {:git, "https://git.qpq.swiss/QPQ-AG/erl-base58.git", "e6aa62eeae3d4388311401f06e4b939bf4e94b9c", [ref: "e6aa62eeae3d4388311401f06e4b939bf4e94b9c"]},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"},
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"eblake2": {:hex, :eblake2, "1.0.0", "ec8ad20e438aab3f2e8d5d118c366a0754219195f8a0f536587440f8f9bcf2ef", [:rebar3], [], "hexpm", "3c4d300a91845b25d501929a26ac2e6f7157480846fab2347a4c11ae52e08a99"},
"ec_utils": {:git, "https://git.qpq.swiss/QPQ-AG/ec_utils.git", "30944928da14e2fa30fc6073491b4271b271303d", []},
"ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"},
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
"elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"},
"eqrcode": {:hex, :eqrcode, "0.2.1", "d12838813e8fc87b8940cc05f9baadb189031f6009facdc56ff074375ec73b6e", [:mix], [], "hexpm", "d5828a222b904c68360e7dc2a40c3ef33a1328b7c074583898040f389f928025"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"gmserialization": {:git, "https://git.qpq.swiss/QPQ-AG/gmserialization.git", "49d1103e5d3400b90e039e3b03a3c91d6d3a3555", [branch: "eureka"]},
"hakuzaru": {:git, "https://git.qpq.swiss/QPQ-AG/hakuzaru.git", "9a7a2a98c497b69de18cf58f22a0f0933d422f96", [ref: "9a7a2a98c4"]},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hex2bin": {:hex, :hex2bin, "1.0.0", "aac26eab998ae80eacee1c7607c629ab503ebf77a62b9242bae2b94d47dcb71e", [:rebar3], [], "hexpm", "e7012d1d9aadd26e680f0983d26fb8923707f05fac9688f19f530fa3795e716f"},
"hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"lazy_html": {:hex, :lazy_html, "0.1.12", "31a55ee622918fce988c94b06232227b42daa64e4eab14ac32081d0f3fd8db6f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "8a0da594776caee58782c6f93b2abaa5bdb809daf8d43351a561f7de9dc2e2a8"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"phoenix": {:hex, :phoenix, "1.8.13", "e33192826d9bed4022bdb3f5a7b36c04362049d9390fd1b581d5ad6261779268", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ad14e24d10e5a52d5f80429053bbe3a5d124311a2868fceb0a01a2e859c44539"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.2.10", "eb4958045f71d4962373e9ed5967b592375a9dafc73f52997f3996759998a39d", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bcf9d64846b770bc64b1a58dc40af406d80eb61b6e516f7aea4cc8cc15e0a1d9"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},
"plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"},
"postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"},
"req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"},
"sha3": {:git, "https://git.qpq.swiss/QPQ-AG/erlang-sha3.git", "77c4e048aea75bd0bc2b5ca6cb02b7230d081c71", [ref: "77c4e048aea75bd0bc2b5ca6cb02b7230d081c71"]},
"swoosh": {:hex, :swoosh, "1.28.0", "30d9f3519a150128e90d97f9878a8a5cd8325ed03d618d1c626201496fc20baf", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bb5c0b7c988beb53786254a61580597fe1017a652039061ee2b4c28b5097b10e"},
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.2.0", "7632c19c01d88d8aaca5da1a0e8912f5af39b79e7c08a2c253aeb3c14c2c957e", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "71dde12fc29b58b9c77ec17ec319109e5ca848d010fc1965ed4463bba1837c07"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
}
+112
View File
@@ -0,0 +1,112 @@
## `msgid`s in this file come from POT (.pot) files.
##
## Do not add, change, or remove `msgid`s manually here as
## they're tied to the ones in the corresponding POT file
## (with the same domain).
##
## Use `mix gettext.extract --merge` or `mix gettext.merge`
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""
+109
View File
@@ -0,0 +1,109 @@
## This is a PO Template file.
##
## `msgid`s here are often extracted from source code.
## Add new translations manually only if they're dynamic
## translations that can't be statically extracted.
##
## Run `mix gettext.extract` to bring this file up to
## date. Leave `msgstr`s empty as changing them here has no
## effect: edit them in PO (`.po`) files instead.
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""
+4
View File
@@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]
@@ -0,0 +1,20 @@
defmodule Dexi.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
execute "CREATE EXTENSION IF NOT EXISTS citext", "DROP EXTENSION IF EXISTS citext"
create table(:users, primary_key: false) do
add :pubkey, :text, primary_key: true
add :email, :citext
add :name, :string
add :role, :string, null: false, default: "user"
timestamps(type: :utc_datetime)
end
create unique_index(:users, [:email], where: "email IS NOT NULL")
create constraint(:users, :users_role_must_be_valid, check: "role IN ('user', 'admin')")
end
end
@@ -0,0 +1,11 @@
defmodule Dexi.Repo.Migrations.AddEmailConfirmationToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :email_confirmed_at, :utc_datetime
add :email_confirmation_code_hash, :binary
add :email_confirmation_sent_at, :utc_datetime
end
end
end
@@ -0,0 +1,22 @@
defmodule Dexi.Repo.Migrations.AddUserSearchIndexes do
use Ecto.Migration
def change do
execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
execute(
"CREATE INDEX users_pubkey_search_index ON users (lower(pubkey) text_pattern_ops)",
"DROP INDEX users_pubkey_search_index"
)
execute(
"CREATE INDEX users_name_search_index ON users USING gin (lower(coalesce(name, '')) gin_trgm_ops)",
"DROP INDEX users_name_search_index"
)
execute(
"CREATE INDEX users_email_search_index ON users USING gin (lower(coalesce(email, '')) gin_trgm_ops)",
"DROP INDEX users_email_search_index"
)
end
end
@@ -0,0 +1,13 @@
defmodule Dexi.Repo.Migrations.AddUserDataConstraints do
use Ecto.Migration
def change do
create constraint(:users, :users_pubkey_must_be_valid,
check: "pubkey ~ '^ak_[1-9A-HJ-NP-Za-km-z]+$'"
)
create constraint(:users, :users_email_must_be_normalized,
check: "email IS NULL OR (email = btrim(email) AND email <> '')"
)
end
end
+11
View File
@@ -0,0 +1,11 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# Dexi.Repo.insert!(%Dexi.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

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

After

Width:  |  Height:  |  Size: 3.0 KiB

+5
View File
@@ -0,0 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
case "${RELEASE_COMMAND:-}" in
migrate)
exec "$RELEASE_PROG" eval 'Dexi.Release.migrate()'
;;
admin)
if [ "$#" -ne 3 ] || { [ "$2" != "--create" ] && [ "$2" != "--remove" ]; }; then
echo "Usage: bin/dexi admin --create <pubkey>" >&2
echo " bin/dexi admin --remove <pubkey>" >&2
exit 64
fi
DEXI_ADMIN_ACTION="$2"
DEXI_ADMIN_PUBKEY="$3"
export DEXI_ADMIN_ACTION DEXI_ADMIN_PUBKEY
exec "$RELEASE_PROG" eval 'Dexi.Release.admin(System.fetch_env!("DEXI_ADMIN_ACTION"), System.fetch_env!("DEXI_ADMIN_PUBKEY"))'
;;
test_users)
if [ "$#" -ne 3 ] || [ "$2" != "--count" ]; then
echo "Usage: bin/dexi test_users --count COUNT" >&2
exit 64
fi
DEXI_TEST_USERS_OPTION="$2"
DEXI_TEST_USERS_COUNT="$3"
export DEXI_TEST_USERS_OPTION DEXI_TEST_USERS_COUNT
exec "$RELEASE_PROG" eval 'Dexi.Release.test_users(System.fetch_env!("DEXI_TEST_USERS_OPTION"), System.fetch_env!("DEXI_TEST_USERS_COUNT"))'
;;
esac
@@ -0,0 +1,35 @@
defmodule Dexi.Accounts.AdministrationTest do
use Dexi.DataCase, async: true
import Dexi.AccountsFixtures
alias Dexi.Accounts
test "admin commands are idempotent" do
pubkey = unique_public_key()
assert {:ok, first} = Accounts.create_or_promote_admin(pubkey)
assert first.role == :admin
assert {:ok, second} = Accounts.create_or_promote_admin(pubkey)
assert second.role == :admin
assert {:ok, user} = Accounts.remove_admin(pubkey)
assert user.role == :user
end
test "fresh database authorization rejects a stale admin struct" do
admin = admin_fixture()
target = user_fixture()
assert {:ok, _user} = Accounts.remove_admin(admin.pubkey)
assert {:error, :unauthorized} = Accounts.set_role(admin, target, :admin)
assert {:error, :unauthorized} =
Accounts.update_user_details(admin, target, %{name: "Not allowed"})
end
test "an admin cannot demote itself through the user interface service" do
admin = admin_fixture()
assert {:error, :self_demotion} = Accounts.set_role(admin, admin, :user)
end
end
@@ -0,0 +1,66 @@
defmodule Dexi.Accounts.EmailConfirmationTest do
use Dexi.DataCase, async: false
import Dexi.AccountsFixtures
import Swoosh.TestAssertions
alias Dexi.Accounts
alias Dexi.Accounts.User
test "sends and verifies a short-lived confirmation code" do
user = user_fixture(%{email: "person@example.com"})
assert {:ok, pending_user} = Accounts.request_email_confirmation(user)
assert is_binary(pending_user.email_confirmation_code_hash)
assert {:error, :rate_limited} = Accounts.request_email_confirmation(pending_user)
assert_email_sent(fn email ->
[code] = Regex.run(~r/\b\d{6}\b/, email.text_body)
send(self(), {:confirmation_code, code})
Enum.any?(email.to, fn {_name, address} -> address == "person@example.com" end)
end)
assert_receive {:confirmation_code, code}
invalid_code = if code == "000000", do: "000001", else: "000000"
assert {:error, :invalid_code} = Accounts.confirm_email(pending_user, invalid_code)
assert {:ok, confirmed_user} = Accounts.confirm_email(pending_user, code)
assert Accounts.email_confirmed?(confirmed_user)
refute confirmed_user.email_confirmation_code_hash
end
test "expired codes are cleared" do
user = pending_user(DateTime.add(DateTime.utc_now(:second), -901, :second))
refute Accounts.email_confirmation_active?(user)
assert {:error, :expired} = Accounts.confirm_email(user, "123456")
reloaded_user = Repo.reload!(user)
refute reloaded_user.email_confirmation_code_hash
refute reloaded_user.email_confirmation_sent_at
end
test "future timestamps are rejected without clearing the pending code" do
user = pending_user(DateTime.add(DateTime.utc_now(:second), 120, :second))
refute Accounts.email_confirmation_active?(user)
assert {:error, :invalid_timestamp} = Accounts.confirm_email(user, "123456")
reloaded_user = Repo.reload!(user)
assert reloaded_user.email_confirmation_code_hash
assert reloaded_user.email_confirmation_sent_at
end
defp pending_user(sent_at) do
user = user_fixture(%{email: "person@example.com"})
{:ok, pending_user} =
user
|> User.email_confirmation_changeset(%{
email_confirmation_code_hash: :crypto.strong_rand_bytes(32),
email_confirmation_sent_at: sent_at
})
|> Repo.update()
pending_user
end
end
+55
View File
@@ -0,0 +1,55 @@
defmodule Dexi.Accounts.SearchTest do
use Dexi.DataCase, async: true
import Dexi.AccountsFixtures
alias Dexi.Accounts
alias Dexi.Accounts.Search
test "search modes are explicit and case-insensitive" do
assert Search.search_mode("") == :all
assert Search.search_mode("a") == :all_fields
assert Search.search_mode("AK") == :all_fields
assert Search.search_mode("ak_123") == :public_key
assert Search.search_mode("person@example.com") == :identity
end
test "a and ak search public keys, names, and emails" do
named = user_fixture(%{name: "Ada"})
emailed = user_fixture(%{email: "ak@example.test"})
assert named.pubkey in Enum.map(Accounts.search_users("a"), & &1.pubkey)
assert emailed.pubkey in Enum.map(Accounts.search_users("ak"), & &1.pubkey)
end
test "ak_ searches public keys while other terms search identity fields" do
user = user_fixture(%{name: "Searchable Person", email: "person@example.test"})
assert user.pubkey in Enum.map(
Accounts.search_users(String.slice(user.pubkey, 0, 8)),
& &1.pubkey
)
assert user.pubkey in Enum.map(Accounts.search_users("searchable"), & &1.pubkey)
assert user.pubkey in Enum.map(Accounts.search_users("example.test"), & &1.pubkey)
end
test "returns stable numbered pages" do
Enum.each(1..4, fn index -> user_fixture(%{name: "Paged #{index}"}) end)
first = Accounts.search_page("Paged", :all, page: 1, per_page: 2)
second = Accounts.search_page("Paged", :all, page: 2, per_page: 2)
assert first.page == 1
assert second.page == 2
assert first.total_entries == 4
assert first.total_pages == 2
assert length(first.entries) == 2
assert length(second.entries) == 2
assert MapSet.disjoint?(
MapSet.new(Enum.map(first.entries, & &1.pubkey)),
MapSet.new(Enum.map(second.entries, & &1.pubkey))
)
end
end
+29
View File
@@ -0,0 +1,29 @@
defmodule Dexi.Accounts.TestUsersTest do
use Dexi.DataCase, async: false
alias Dexi.Accounts.TestUsers
test "creates valid test users in bulk" do
assert {:ok, users} = TestUsers.create(3)
assert length(users) == 3
assert Enum.all?(users, &(&1.role == :user))
assert Enum.all?(users, &String.starts_with?(&1.pubkey, "ak_"))
end
test "bounds the bulk size" do
assert {:error, _message} = TestUsers.create(0)
assert {:error, _message} = TestUsers.create(10_001)
end
test "can be disabled outside controlled environments" do
previous = Application.get_env(:dexi, :allow_test_users)
Application.put_env(:dexi, :allow_test_users, false)
on_exit(fn ->
Application.put_env(:dexi, :allow_test_users, previous)
end)
assert {:error, message} = TestUsers.create(1)
assert message =~ "disabled"
end
end
+48
View File
@@ -0,0 +1,48 @@
defmodule Dexi.AccountsTest do
use Dexi.DataCase, async: true
import Dexi.AccountsFixtures
alias Dexi.Accounts
alias Dexi.Accounts.User
test "a new public key creates a user and subsequent logins return it" do
pubkey = unique_public_key()
assert {:ok, %User{pubkey: ^pubkey, role: :user, email: nil}, :created} =
Accounts.get_or_create_user_with_status(pubkey)
assert {:ok, %User{pubkey: ^pubkey}, :existing} =
Accounts.get_or_create_user_with_status(pubkey)
assert Repo.aggregate(User, :count) == 1
end
test "profile keeps email and name optional while validating supplied email" do
user = user_fixture()
assert {:error, changeset} = Accounts.update_profile(user, %{email: "invalid"})
assert %{email: [_message]} = errors_on(changeset)
assert {:ok, %User{email: nil, name: nil}} =
Accounts.update_profile(user, %{email: " ", name: " "})
assert {:ok, %User{email: "person@example.com", name: nil}} =
Accounts.update_profile(user, %{email: " PERSON@EXAMPLE.COM ", name: " "})
end
test "changing email invalidates its confirmation" do
user = user_fixture(%{email: "first@example.com"})
{:ok, confirmed_user} =
user
|> User.email_confirmation_changeset(%{email_confirmed_at: DateTime.utc_now(:second)})
|> Repo.update()
assert {:ok, changed_user} =
Accounts.update_profile(confirmed_user, %{email: "second@example.com"})
refute changed_user.email_confirmed_at
refute changed_user.email_confirmation_code_hash
end
end
+21
View File
@@ -0,0 +1,21 @@
defmodule Dexi.CLI.ArgumentsTest do
use ExUnit.Case, async: true
alias Dexi.CLI.Arguments
test "parses admin operations" do
assert {:ok, "--create", "ak_example"} =
Arguments.parse_admin(["--create", "ak_example"])
assert {:ok, "--remove", "ak_example"} =
Arguments.parse_admin(["--remove", "ak_example"])
assert {:error, _message} = Arguments.parse_admin(["--delete", "ak_example"])
end
test "parses positive bulk counts" do
assert {:ok, 100} = Arguments.parse_count(["--count", "100"])
assert {:error, _message} = Arguments.parse_count(["--count", "0"])
assert {:error, _message} = Arguments.parse_count(["--count", "many"])
end
end
+19
View File
@@ -0,0 +1,19 @@
defmodule :zj_test do
use ExUnit.Case, async: true
test "decode returns the charlist structures expected by Hakuzaru" do
assert {:ok,
%{
~c"network_id" => ~c"groot.testnet",
~c"listening" => true,
~c"optional" => :undefined
}} =
:zj.decode(~s({"network_id":"groot.testnet","listening":true,"optional":null}))
end
test "binary encoding supports Hakuzaru request maps" do
assert Jason.decode!(:zj.binary_encode(%{tx: "tx_signed"})) == %{
"tx" => "tx_signed"
}
end
end
@@ -0,0 +1,14 @@
defmodule DexiWeb.ErrorHTMLTest do
use DexiWeb.ConnCase, async: true
# Bring render_to_string/4 for testing custom views
import Phoenix.Template, only: [render_to_string: 4]
test "renders 404.html" do
assert render_to_string(DexiWeb.ErrorHTML, "404", "html", []) == "Not Found"
end
test "renders 500.html" do
assert render_to_string(DexiWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
end
end
@@ -0,0 +1,12 @@
defmodule DexiWeb.ErrorJSONTest do
use DexiWeb.ConnCase, async: true
test "renders 404" do
assert DexiWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
end
test "renders 500" do
assert DexiWeb.ErrorJSON.render("500.json", %{}) ==
%{errors: %{detail: "Internal Server Error"}}
end
end
@@ -0,0 +1,8 @@
defmodule DexiWeb.PageControllerTest do
use DexiWeb.ConnCase
test "GET /", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Hi, I am Dexi"
end
end
@@ -0,0 +1,49 @@
defmodule DexiWeb.SessionControllerTest do
use DexiWeb.ConnCase
import Dexi.AccountsFixtures
alias Dexi.Accounts
alias Dexi.GridsLoginHandler
test "a completed GRIDS login creates an account and starts a session", %{conn: conn} do
conn = get(conn, ~p"/login")
session_id = get_session(conn, :session_id)
grids_url = GridsLoginHandler.get_signature_url(session_id)
message_id = grids_url |> String.split("/") |> List.last()
pubkey = unique_public_key()
assert :ok = GridsLoginHandler.write_pubkey(message_id, pubkey)
conn = get(conn, ~p"/session/#{message_id}")
assert redirected_to(conn) == ~p"/account"
assert get_session(conn, :user_pubkey) == pubkey
assert Accounts.get_user_by_pubkey(pubkey)
end
test "a returning account is sent to the home page", %{conn: conn} do
pubkey = unique_public_key()
assert {:ok, _user} = Accounts.get_or_create_user(pubkey)
conn = get(conn, ~p"/login")
session_id = get_session(conn, :session_id)
grids_url = GridsLoginHandler.get_signature_url(session_id)
message_id = grids_url |> String.split("/") |> List.last()
assert :ok = GridsLoginHandler.write_pubkey(message_id, pubkey)
conn = get(conn, ~p"/session/#{message_id}")
assert redirected_to(conn) == ~p"/"
assert get_session(conn, :user_pubkey) == pubkey
end
test "an unsigned or consumed challenge cannot start a session", %{conn: conn} do
conn = get(conn, ~p"/login")
conn = get(conn, ~p"/session/not-a-message")
assert redirected_to(conn) == ~p"/login"
refute get_session(conn, :user_pubkey)
end
end
@@ -0,0 +1,42 @@
defmodule DexiWeb.SignTxControllerTest do
use DexiWeb.ConnCase, async: false
alias Dexi.GridsCallData
setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end
test "GET /api/sign/:message_id returns the stashed unsigned transaction", %{conn: conn} do
{:ok, message_id} =
GridsCallData.stash({self(), "ak_expected_public_key", "tx_unsigned"})
conn = get(conn, ~p"/api/sign/#{message_id}")
assert %{
"network_id" => "test-network-id",
"payload" => "tx_unsigned",
"public_id" => "ak_expected_public_key",
"type" => "tx"
} = json_response(conn, 200)
end
test "POST rejects a public key that does not match the stashed request", %{conn: conn} do
{:ok, message_id} =
GridsCallData.stash({self(), "ak_expected_public_key", "tx_unsigned"})
conn =
post(conn, ~p"/api/sign/#{message_id}", %{
"chain" => "gajumaru",
"grids" => 1,
"network_id" => "test-network-id",
"payload" => "tx_signed",
"public_id" => "ak_other_public_key",
"type" => "tx"
})
assert %{"error" => "not_found"} = json_response(conn, 404)
refute_receive :tx_failed
refute_receive {:tx_success, _tx_hash}
end
end
@@ -0,0 +1,68 @@
defmodule DexiWeb.Admin.UsersLiveTest do
use DexiWeb.ConnCase
import Dexi.AccountsFixtures
import Phoenix.LiveViewTest
alias Dexi.Accounts
test "an admin can edit an account from its card", %{conn: conn} do
admin_pubkey = unique_public_key()
user_pubkey = unique_public_key()
assert {:ok, admin} = Accounts.create_or_promote_admin(admin_pubkey)
assert {:ok, _user} = Accounts.get_or_create_user(user_pubkey)
conn = init_test_session(conn, %{user_pubkey: admin.pubkey})
{:ok, view, _html} = live(conn, ~p"/admin/users")
view
|> element("#edit-user-#{user_pubkey}")
|> render_click()
assert has_element?(view, "#edit-user-modal")
view
|> form("#edit-user-form", user: %{name: "Edited User", email: "edited@example.com"})
|> render_submit()
refute has_element?(view, "#edit-user-modal")
assert %{name: "Edited User", email: "edited@example.com"} =
Accounts.get_user_by_pubkey(user_pubkey)
end
test "a shared URL restores search, role, and page", %{conn: conn} do
admin = admin_fixture()
Enum.each(1..21, fn index ->
user_fixture(%{name: "Shareable User #{index}"})
end)
conn = init_test_session(conn, %{user_pubkey: admin.pubkey})
path = ~p"/admin/users?#{%{page: 2, search: "Shareable", role: "user"}}"
{:ok, view, _html} = live(conn, path)
assert has_element?(view, "#users-page-2[aria-current=page]")
assert has_element?(view, "#search_query[value='Shareable']")
assert has_element?(view, "#search_role option[value='user'][selected]")
assert has_element?(view, "#previous-users-page")
refute has_element?(view, "#next-users-page")
end
test "changing search criteria updates the shareable URL", %{conn: conn} do
admin = admin_fixture()
conn = init_test_session(conn, %{user_pubkey: admin.pubkey})
{:ok, view, _html} = live(conn, ~p"/admin/users")
view
|> form("#user-search-form", search: %{query: "alice", role: "admin"})
|> render_change()
assert_patch(
view,
~p"/admin/users?#{%{page: 1, search: "alice", role: "admin"}}"
)
end
end
+38
View File
@@ -0,0 +1,38 @@
defmodule DexiWeb.ConnCase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a connection.
Such tests rely on `Phoenix.ConnTest` and also
import other functionality to make it easier
to build common data structures and query the data layer.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use DexiWeb.ConnCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
# The default endpoint for testing
@endpoint DexiWeb.Endpoint
use DexiWeb, :verified_routes
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
import DexiWeb.ConnCase
end
end
setup tags do
Dexi.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end
+58
View File
@@ -0,0 +1,58 @@
defmodule Dexi.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use Dexi.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
alias Dexi.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import Dexi.DataCase
end
end
setup tags do
Dexi.DataCase.setup_sandbox(tags)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Dexi.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
@doc """
A helper that transforms changeset errors into a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end
@@ -0,0 +1,34 @@
defmodule Dexi.AccountsFixtures do
alias Dexi.Accounts
def unique_public_key do
{pubkey, _key_pair} = :hz_key_master.make_key(:crypto.strong_rand_bytes(32))
to_string(pubkey)
end
def user_fixture(attrs \\ %{}) do
pubkey = Map.get(attrs, :pubkey, unique_public_key())
{:ok, user} = Accounts.get_or_create_user(pubkey)
profile_attrs = Map.take(attrs, [:name, :email])
user =
if profile_attrs == %{} do
user
else
{:ok, updated_user} = Accounts.update_profile(user, profile_attrs)
updated_user
end
case Map.get(attrs, :role, :user) do
:admin ->
{:ok, admin} = Accounts.create_or_promote_admin(user.pubkey)
admin
:user ->
user
end
end
def admin_fixture(attrs \\ %{}), do: user_fixture(Map.put(attrs, :role, :admin))
end
+2
View File
@@ -0,0 +1,2 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Dexi.Repo, :manual)