Initial commit: login and users
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Generated
+1061
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"tailwindcss": "^4.3.3"
|
||||
}
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
Vendored
+43
@@ -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})
|
||||
})
|
||||
Vendored
+138
@@ -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));
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
defmodule Dexi.Mailer do
|
||||
use Swoosh.Mailer, otp_app: :dexi
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
defmodule Dexi.Repo do
|
||||
use Ecto.Repo,
|
||||
otp_app: :dexi,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
end
|
||||
+114
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"},
|
||||
}
|
||||
@@ -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 ""
|
||||
@@ -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 ""
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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 |
@@ -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: /
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
ExUnit.start()
|
||||
Ecto.Adapters.SQL.Sandbox.mode(Dexi.Repo, :manual)
|
||||
Reference in New Issue
Block a user