commit 145f1e5f7b9d1b0d764367a359a9c6e0e3e4b9db Author: Dimitar Ivanov Date: Thu Aug 27 15:35:57 2026 +0300 Initial commit: login and users diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..ef8840c --- /dev/null +++ b/.formatter.exs @@ -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"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b9f67a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..73a6e08 --- /dev/null +++ b/AGENTS.md @@ -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 `` 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 `` + - **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 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 + + + + + +## 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 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 + + + +## 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 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 `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
+
+      
+        let obj = {key: "val"}
+      
+
+  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**:
+
+      Text
+
+  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 `]`):
+
+       ...
+      => 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:
+
+      
+ {@my_assign} + <%= if @some_block_condition do %> + {@another_assign} + <% end %> +
+ + and **Never** do this – the program will terminate with a syntax error: + + <%!-- THIS IS INVALID NEVER EVER DO THIS --%> +
+ {if @invalid_block_construct do} + {end} +
+ + + +## 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: + +
+
+ {msg.text} +
+
+ +- 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: + +
+ +
+ {task.name} +
+
+ + 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: + +
+
+ {message.username} + <%= if @editing_message_id == message.id do %> + <%!-- Edit mode --%> + <.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit"> + ... + + <% end %> +
+
+ +- **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 ` + +- 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 (`
`) 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" /> + + +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" /> + + +And **never** do this: + + <%!-- NEVER do this (invalid) --%> + <.form for={@changeset} id="my-form"> + <.input field={@changeset[:field]} type="text" /> + + +- 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 + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb5f339 --- /dev/null +++ b/README.md @@ -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 diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..a302638 --- /dev/null +++ b/assets/css/app.css @@ -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); + } +} diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..002eee4 --- /dev/null +++ b/assets/js/app.js @@ -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 `` 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 + }) +} diff --git a/assets/package-lock.json b/assets/package-lock.json new file mode 100644 index 0000000..f942eab --- /dev/null +++ b/assets/package-lock.json @@ -0,0 +1,1061 @@ +{ + "name": "assets", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@tailwindcss/cli": "^4.3.3", + "tailwindcss": "^4.3.3" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", + "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==", + "license": "MIT", + "dependencies": { + "@parcel/watcher": "2.5.1", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "enhanced-resolve": "^5.24.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.3.3" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/assets/package.json b/assets/package.json new file mode 100644 index 0000000..c0a542c --- /dev/null +++ b/assets/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "@tailwindcss/cli": "^4.3.3", + "tailwindcss": "^4.3.3" + } +} diff --git a/assets/tsconfig.json b/assets/tsconfig.json new file mode 100644 index 0000000..a9401b6 --- /dev/null +++ b/assets/tsconfig.json @@ -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/**/*"] +} diff --git a/assets/vendor/heroicons.js b/assets/vendor/heroicons.js new file mode 100644 index 0000000..296f80e --- /dev/null +++ b/assets/vendor/heroicons.js @@ -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}) +}) diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js new file mode 100644 index 0000000..0552337 --- /dev/null +++ b/assets/vendor/topbar.js @@ -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)); diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..cc31a76 --- /dev/null +++ b/config/config.exs @@ -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" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..a46fcdf --- /dev/null +++ b/config/dev.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" diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..30951fc --- /dev/null +++ b/config/prod.exs @@ -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. diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..4b1994a --- /dev/null +++ b/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 diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..b0438af --- /dev/null +++ b/config/test.exs @@ -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" diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..0fa87db --- /dev/null +++ b/docs/CLI.md @@ -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 +``` + +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 +``` + +## Remove administrator access + +```sh +bin/dexi admin --remove +``` + +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 +``` + +## Create test users + +```sh +bin/dexi test_users --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 +``` diff --git a/lib/dexi.ex b/lib/dexi.ex new file mode 100644 index 0000000..99ff009 --- /dev/null +++ b/lib/dexi.ex @@ -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 diff --git a/lib/dexi/accounts.ex b/lib/dexi/accounts.ex new file mode 100644 index 0000000..94d1bd6 --- /dev/null +++ b/lib/dexi/accounts.ex @@ -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 diff --git a/lib/dexi/accounts/admin_command.ex b/lib/dexi/accounts/admin_command.ex new file mode 100644 index 0000000..a1d4569 --- /dev/null +++ b/lib/dexi/accounts/admin_command.ex @@ -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 diff --git a/lib/dexi/accounts/administration.ex b/lib/dexi/accounts/administration.ex new file mode 100644 index 0000000..289440e --- /dev/null +++ b/lib/dexi/accounts/administration.ex @@ -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 diff --git a/lib/dexi/accounts/email_confirmation.ex b/lib/dexi/accounts/email_confirmation.ex new file mode 100644 index 0000000..8d5521b --- /dev/null +++ b/lib/dexi/accounts/email_confirmation.ex @@ -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 diff --git a/lib/dexi/accounts/scope.ex b/lib/dexi/accounts/scope.ex new file mode 100644 index 0000000..9609d17 --- /dev/null +++ b/lib/dexi/accounts/scope.ex @@ -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 diff --git a/lib/dexi/accounts/search.ex b/lib/dexi/accounts/search.ex new file mode 100644 index 0000000..dbfc6a3 --- /dev/null +++ b/lib/dexi/accounts/search.ex @@ -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 diff --git a/lib/dexi/accounts/search_page.ex b/lib/dexi/accounts/search_page.ex new file mode 100644 index 0000000..b9702fd --- /dev/null +++ b/lib/dexi/accounts/search_page.ex @@ -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 diff --git a/lib/dexi/accounts/test_users.ex b/lib/dexi/accounts/test_users.ex new file mode 100644 index 0000000..ca6552b --- /dev/null +++ b/lib/dexi/accounts/test_users.ex @@ -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 diff --git a/lib/dexi/accounts/user.ex b/lib/dexi/accounts/user.ex new file mode 100644 index 0000000..9124264 --- /dev/null +++ b/lib/dexi/accounts/user.ex @@ -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 diff --git a/lib/dexi/accounts/user_notifier.ex b/lib/dexi/accounts/user_notifier.ex new file mode 100644 index 0000000..94c81f6 --- /dev/null +++ b/lib/dexi/accounts/user_notifier.ex @@ -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(""" +
+

Confirm your email

+

Enter this code in Dexi:

+

#{code}

+

This code expires in 15 minutes. If you did not request it, you can ignore this email.

+
+ """) + |> Mailer.deliver() + end +end diff --git a/lib/dexi/application.ex b/lib/dexi/application.ex new file mode 100644 index 0000000..15f1eaf --- /dev/null +++ b/lib/dexi/application.ex @@ -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 diff --git a/lib/dexi/chain_transactions.ex b/lib/dexi/chain_transactions.ex new file mode 100644 index 0000000..7403ebf --- /dev/null +++ b/lib/dexi/chain_transactions.ex @@ -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 diff --git a/lib/dexi/cli/arguments.ex b/lib/dexi/cli/arguments.ex new file mode 100644 index 0000000..6123725 --- /dev/null +++ b/lib/dexi/cli/arguments.ex @@ -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 diff --git a/lib/dexi/grids.ex b/lib/dexi/grids.ex new file mode 100644 index 0000000..bef4160 --- /dev/null +++ b/lib/dexi/grids.ex @@ -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 diff --git a/lib/dexi/grids/dead_drop.ex b/lib/dexi/grids/dead_drop.ex new file mode 100644 index 0000000..cc41bbb --- /dev/null +++ b/lib/dexi/grids/dead_drop.ex @@ -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 diff --git a/lib/dexi/grids/public_id.ex b/lib/dexi/grids/public_id.ex new file mode 100644 index 0000000..2ff803d --- /dev/null +++ b/lib/dexi/grids/public_id.ex @@ -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 diff --git a/lib/dexi/grids_call_data.ex b/lib/dexi/grids_call_data.ex new file mode 100644 index 0000000..c829967 --- /dev/null +++ b/lib/dexi/grids_call_data.ex @@ -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 diff --git a/lib/dexi/grids_login_handler.ex b/lib/dexi/grids_login_handler.ex new file mode 100644 index 0000000..8bf21e0 --- /dev/null +++ b/lib/dexi/grids_login_handler.ex @@ -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 diff --git a/lib/dexi/login_handler/message_data.ex b/lib/dexi/login_handler/message_data.ex new file mode 100644 index 0000000..62f6bbb --- /dev/null +++ b/lib/dexi/login_handler/message_data.ex @@ -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 diff --git a/lib/dexi/mailer.ex b/lib/dexi/mailer.ex new file mode 100644 index 0000000..f20001e --- /dev/null +++ b/lib/dexi/mailer.ex @@ -0,0 +1,3 @@ +defmodule Dexi.Mailer do + use Swoosh.Mailer, otp_app: :dexi +end diff --git a/lib/dexi/release.ex b/lib/dexi/release.ex new file mode 100644 index 0000000..0f052eb --- /dev/null +++ b/lib/dexi/release.ex @@ -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 diff --git a/lib/dexi/repo.ex b/lib/dexi/repo.ex new file mode 100644 index 0000000..1d31875 --- /dev/null +++ b/lib/dexi/repo.ex @@ -0,0 +1,5 @@ +defmodule Dexi.Repo do + use Ecto.Repo, + otp_app: :dexi, + adapter: Ecto.Adapters.Postgres +end diff --git a/lib/dexi_web.ex b/lib/dexi_web.ex new file mode 100644 index 0000000..23f96ae --- /dev/null +++ b/lib/dexi_web.ex @@ -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 diff --git a/lib/dexi_web/components/account_components.ex b/lib/dexi_web/components/account_components.ex new file mode 100644 index 0000000..89df2b5 --- /dev/null +++ b/lib/dexi_web/components/account_components.ex @@ -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""" +
+
+ <.detail label="Public key" value={@user.pubkey} id="account-pubkey" wide monospace /> + <.detail label="Name" value={@user.name || "Not provided"} id="account-name" /> +
+ <.detail label="Email" value={@user.email || "Not provided"} id="account-email" /> +

+ Email confirmed +

+
+ <.detail label="Role" value={@user.role} id="account-role" capitalize /> +
+ + +
+ """ + end + + attr :form, :any, required: true + + def profile_editor(assigns) do + ~H""" +
+ + + <.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)" /> + + +
+ """ + end + + attr :user, :any, required: true + attr :form, :any, required: true + + def email_confirmation_panel(assigns) do + ~H""" +
+

Confirm {@user.email}

+

+ Enter the six-digit code sent to your email. Codes expire after 15 minutes. +

+ + + + <.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 + /> +
+ + +
+ +
+ """ + 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""" +
+

{@label}

+

+ {@value} +

+
+ """ + end +end diff --git a/lib/dexi_web/components/core_components.ex b/lib/dexi_web/components/core_components.ex new file mode 100644 index 0000000..8225621 --- /dev/null +++ b/lib/dexi_web/components/core_components.ex @@ -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! + + """ + 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""" +
hide("##{@id}")} + role="alert" + class="fixed right-4 top-4 z-50 flex flex-col gap-3" + {@rest} + > +
+ <.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" /> +
+

{@title}

+

{msg}

+
+
+ +
+
+ """ + end + + @doc """ + Renders a button with navigation support. + + ## Examples + + <.button>Send! + <.button phx-click="go" variant="primary">Send! + <.button navigate={~p"/"}>Home + """ + 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)} + + """ + else + ~H""" + + """ + 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 ` + """ + end + + def input(%{type: "checkbox"} = assigns) do + assigns = + assign_new(assigns, :checked, fn -> + Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) + end) + + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "select"} = assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # Helper used by inputs to generate form errors + defp error(assigns) do + ~H""" +

+ <.icon name="hero-exclamation-circle" class="size-5" /> + {render_slot(@inner_block)} +

+ """ + end + + @doc """ + Renders a header with title. + """ + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" +
+
+

+ {render_slot(@inner_block)} +

+

+ {render_slot(@subtitle)} +

+
+
{render_slot(@actions)}
+
+ """ + end + + @doc """ + Renders a table with generic styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id">{user.id} + <:col :let={user} label="username">{user.username} + + """ + 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""" + + + + + + + + + + + + + +
{col[:label]} + {gettext("Actions")} +
+ {render_slot(col, @row_item.(row))} + +
+ <%= for action <- @action do %> + {render_slot(action, @row_item.(row))} + <% end %> +
+
+ """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title">{@post.title} + <:item title="Views">{@post.views} + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" +
    +
  • +
    +
    {item.title}
    +
    {render_slot(item)}
    +
    +
  • +
+ """ + 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""" + + """ + 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 diff --git a/lib/dexi_web/components/layouts.ex b/lib/dexi_web/components/layouts.ex new file mode 100644 index 0000000..87e5307 --- /dev/null +++ b/lib/dexi_web/components/layouts.ex @@ -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 + + +

Content

+
+ + """ + 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""" +
+ +
+ +
+ {render_slot(@inner_block)} +
+ + <.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""" +
+ <.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 + 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" /> + +
+ """ + end + + @doc """ + Provides dark vs light theme toggle based on themes defined in app.css. + + See in root.html.heex which applies the theme before page load. + """ + def theme_toggle(assigns) do + ~H""" +
+
+ + + + + + +
+ """ + end +end diff --git a/lib/dexi_web/components/layouts/root.html.heex b/lib/dexi_web/components/layouts/root.html.heex new file mode 100644 index 0000000..c5fbb54 --- /dev/null +++ b/lib/dexi_web/components/layouts/root.html.heex @@ -0,0 +1,44 @@ + + + + + + + <.live_title default="Dexi" suffix=" · Dexi" phx-no-format>{assigns[:page_title]} + + + + + + {@inner_content} + + diff --git a/lib/dexi_web/controllers/dead_drop_controller.ex b/lib/dexi_web/controllers/dead_drop_controller.ex new file mode 100644 index 0000000..0397916 --- /dev/null +++ b/lib/dexi_web/controllers/dead_drop_controller.ex @@ -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 diff --git a/lib/dexi_web/controllers/error_html.ex b/lib/dexi_web/controllers/error_html.ex new file mode 100644 index 0000000..d4c5530 --- /dev/null +++ b/lib/dexi_web/controllers/error_html.ex @@ -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 diff --git a/lib/dexi_web/controllers/error_json.ex b/lib/dexi_web/controllers/error_json.ex new file mode 100644 index 0000000..1cbe265 --- /dev/null +++ b/lib/dexi_web/controllers/error_json.ex @@ -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 diff --git a/lib/dexi_web/controllers/page_controller.ex b/lib/dexi_web/controllers/page_controller.ex new file mode 100644 index 0000000..5bd9d11 --- /dev/null +++ b/lib/dexi_web/controllers/page_controller.ex @@ -0,0 +1,7 @@ +defmodule DexiWeb.PageController do + use DexiWeb, :controller + + def home(conn, _params) do + render(conn, :home) + end +end diff --git a/lib/dexi_web/controllers/page_html.ex b/lib/dexi_web/controllers/page_html.ex new file mode 100644 index 0000000..922eede --- /dev/null +++ b/lib/dexi_web/controllers/page_html.ex @@ -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 diff --git a/lib/dexi_web/controllers/page_html/home.html.heex b/lib/dexi_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..bd00bef --- /dev/null +++ b/lib/dexi_web/controllers/page_html/home.html.heex @@ -0,0 +1,21 @@ + +
+
+

+ Hi, I am Dexi - the DEX interface from QPQ IaaS AG +

+ <.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 + +
+
+
diff --git a/lib/dexi_web/controllers/session_controller.ex b/lib/dexi_web/controllers/session_controller.ex new file mode 100644 index 0000000..aff10ae --- /dev/null +++ b/lib/dexi_web/controllers/session_controller.ex @@ -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 diff --git a/lib/dexi_web/controllers/sign_tx_controller.ex b/lib/dexi_web/controllers/sign_tx_controller.ex new file mode 100644 index 0000000..be718dd --- /dev/null +++ b/lib/dexi_web/controllers/sign_tx_controller.ex @@ -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 diff --git a/lib/dexi_web/endpoint.ex b/lib/dexi_web/endpoint.ex new file mode 100644 index 0000000..ba0a8bb --- /dev/null +++ b/lib/dexi_web/endpoint.ex @@ -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 diff --git a/lib/dexi_web/gettext.ex b/lib/dexi_web/gettext.ex new file mode 100644 index 0000000..36de9c5 --- /dev/null +++ b/lib/dexi_web/gettext.ex @@ -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 diff --git a/lib/dexi_web/live/admin/user_list_params.ex b/lib/dexi_web/live/admin/user_list_params.ex new file mode 100644 index 0000000..2ced604 --- /dev/null +++ b/lib/dexi_web/live/admin/user_list_params.ex @@ -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 diff --git a/lib/dexi_web/live/admin/users_live.ex b/lib/dexi_web/live/admin/users_live.ex new file mode 100644 index 0000000..0872e17 --- /dev/null +++ b/lib/dexi_web/live/admin/users_live.ex @@ -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""" + +
+
+

+ Administration +

+

Users

+

+ Search users, update profile details, and manage administrative access. +

+
+ + <.form + for={@search_form} + id="user-search-form" + phx-change="search" + class="mb-6 flex max-w-3xl items-end gap-3" + > +
+ <.input + field={@search_form[:query]} + type="search" + label="Search users" + placeholder="Public key, name, or email" + phx-debounce="250" + /> +
+
+ <.input + field={@search_form[:role]} + type="select" + label="Role" + options={[{"All", "all"}, {"Users", "user"}, {"Admins", "admin"}]} + /> +
+ + +
+ {@total_entries} {if(@total_entries == 1, do: "user", else: "users")} +
+ +
+ + +
+ + +
+ + +
+
+
+ + + + +
+
+ """ + end +end diff --git a/lib/dexi_web/live/login_live.ex b/lib/dexi_web/live/login_live.ex new file mode 100644 index 0000000..3e6095a --- /dev/null +++ b/lib/dexi_web/live/login_live.ex @@ -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""" + +
+

+ Wallet authentication +

+

+ Log in to Dexi +

+

+ Sign this one-time message with the account key you want to use. A new key creates a new user account. +

+ +
+
+ {raw(@qr_code)} +
+ + + +
+ +
    +
  1. + 1. Open your wallet and select an account. +
  2. +
  3. + 2. Open GRIDS URL and paste the code if needed. +
  4. +
  5. + 3. Approve the message signature. +
  6. +
+
+
+ """ + 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 diff --git a/lib/dexi_web/live/profile_live.ex b/lib/dexi_web/live/profile_live.ex new file mode 100644 index 0000000..15eaabc --- /dev/null +++ b/lib/dexi_web/live/profile_live.ex @@ -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""" + +
+

Account

+

Your details

+

+ Your public key is your identity. Email and name are optional. +

+ +
+ + + +
+
+
+ """ + 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 diff --git a/lib/dexi_web/plugs/session_id_plug.ex b/lib/dexi_web/plugs/session_id_plug.ex new file mode 100644 index 0000000..e7f5320 --- /dev/null +++ b/lib/dexi_web/plugs/session_id_plug.ex @@ -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 diff --git a/lib/dexi_web/router.ex b/lib/dexi_web/router.ex new file mode 100644 index 0000000..af82d75 --- /dev/null +++ b/lib/dexi_web/router.ex @@ -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 diff --git a/lib/dexi_web/telemetry.ex b/lib/dexi_web/telemetry.ex new file mode 100644 index 0000000..bd871d5 --- /dev/null +++ b/lib/dexi_web/telemetry.ex @@ -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 diff --git a/lib/dexi_web/user_auth.ex b/lib/dexi_web/user_auth.ex new file mode 100644 index 0000000..82ace0f --- /dev/null +++ b/lib/dexi_web/user_auth.ex @@ -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 diff --git a/lib/mix/tasks/dexi.admin.ex b/lib/mix/tasks/dexi.admin.ex new file mode 100644 index 0000000..d6ec9ea --- /dev/null +++ b/lib/mix/tasks/dexi.admin.ex @@ -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 diff --git a/lib/mix/tasks/dexi.test_users.ex b/lib/mix/tasks/dexi.test_users.ex new file mode 100644 index 0000000..011d099 --- /dev/null +++ b/lib/mix/tasks/dexi.test_users.ex @@ -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 diff --git a/lib/zj.ex b/lib/zj.ex new file mode 100644 index 0000000..a5bb7cf --- /dev/null +++ b/lib/zj.ex @@ -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 diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..a77e916 --- /dev/null +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..13f4a2e --- /dev/null +++ b/mix.lock @@ -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"}, +} diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..844c4f5 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -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 "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot new file mode 100644 index 0000000..eef2de2 --- /dev/null +++ b/priv/gettext/errors.pot @@ -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 "" diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/migrations/20260826134703_create_users.exs b/priv/repo/migrations/20260826134703_create_users.exs new file mode 100644 index 0000000..83c59f8 --- /dev/null +++ b/priv/repo/migrations/20260826134703_create_users.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 diff --git a/priv/repo/migrations/20260827071811_add_email_confirmation_to_users.exs b/priv/repo/migrations/20260827071811_add_email_confirmation_to_users.exs new file mode 100644 index 0000000..4d62f45 --- /dev/null +++ b/priv/repo/migrations/20260827071811_add_email_confirmation_to_users.exs @@ -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 diff --git a/priv/repo/migrations/20260827110646_add_user_search_indexes.exs b/priv/repo/migrations/20260827110646_add_user_search_indexes.exs new file mode 100644 index 0000000..afaf042 --- /dev/null +++ b/priv/repo/migrations/20260827110646_add_user_search_indexes.exs @@ -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 diff --git a/priv/repo/migrations/20260827121622_add_user_data_constraints.exs b/priv/repo/migrations/20260827121622_add_user_data_constraints.exs new file mode 100644 index 0000000..0f14bc2 --- /dev/null +++ b/priv/repo/migrations/20260827121622_add_user_data_constraints.exs @@ -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 diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..02404fd --- /dev/null +++ b/priv/repo/seeds.exs @@ -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. diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000..7f372bf Binary files /dev/null and b/priv/static/favicon.ico differ diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/priv/static/robots.txt b/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/priv/static/robots.txt @@ -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: / diff --git a/rel/env.sh.eex b/rel/env.sh.eex new file mode 100644 index 0000000..b0fa863 --- /dev/null +++ b/rel/env.sh.eex @@ -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 " >&2 + echo " bin/dexi admin --remove " >&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 diff --git a/test/dexi/accounts/administration_test.exs b/test/dexi/accounts/administration_test.exs new file mode 100644 index 0000000..91e8418 --- /dev/null +++ b/test/dexi/accounts/administration_test.exs @@ -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 diff --git a/test/dexi/accounts/email_confirmation_test.exs b/test/dexi/accounts/email_confirmation_test.exs new file mode 100644 index 0000000..3314cb1 --- /dev/null +++ b/test/dexi/accounts/email_confirmation_test.exs @@ -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 diff --git a/test/dexi/accounts/search_test.exs b/test/dexi/accounts/search_test.exs new file mode 100644 index 0000000..91c7233 --- /dev/null +++ b/test/dexi/accounts/search_test.exs @@ -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 diff --git a/test/dexi/accounts/test_users_test.exs b/test/dexi/accounts/test_users_test.exs new file mode 100644 index 0000000..f413f89 --- /dev/null +++ b/test/dexi/accounts/test_users_test.exs @@ -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 diff --git a/test/dexi/accounts_test.exs b/test/dexi/accounts_test.exs new file mode 100644 index 0000000..1b740f1 --- /dev/null +++ b/test/dexi/accounts_test.exs @@ -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 diff --git a/test/dexi/cli/arguments_test.exs b/test/dexi/cli/arguments_test.exs new file mode 100644 index 0000000..f44213f --- /dev/null +++ b/test/dexi/cli/arguments_test.exs @@ -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 diff --git a/test/dexi/zj_test.exs b/test/dexi/zj_test.exs new file mode 100644 index 0000000..e2bf1ce --- /dev/null +++ b/test/dexi/zj_test.exs @@ -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 diff --git a/test/dexi_web/controllers/error_html_test.exs b/test/dexi_web/controllers/error_html_test.exs new file mode 100644 index 0000000..46d515d --- /dev/null +++ b/test/dexi_web/controllers/error_html_test.exs @@ -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 diff --git a/test/dexi_web/controllers/error_json_test.exs b/test/dexi_web/controllers/error_json_test.exs new file mode 100644 index 0000000..f4b16fa --- /dev/null +++ b/test/dexi_web/controllers/error_json_test.exs @@ -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 diff --git a/test/dexi_web/controllers/page_controller_test.exs b/test/dexi_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..175aca4 --- /dev/null +++ b/test/dexi_web/controllers/page_controller_test.exs @@ -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 diff --git a/test/dexi_web/controllers/session_controller_test.exs b/test/dexi_web/controllers/session_controller_test.exs new file mode 100644 index 0000000..cf4c6fe --- /dev/null +++ b/test/dexi_web/controllers/session_controller_test.exs @@ -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 diff --git a/test/dexi_web/controllers/sign_tx_controller_test.exs b/test/dexi_web/controllers/sign_tx_controller_test.exs new file mode 100644 index 0000000..7767b5f --- /dev/null +++ b/test/dexi_web/controllers/sign_tx_controller_test.exs @@ -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 diff --git a/test/dexi_web/live/admin/users_live_test.exs b/test/dexi_web/live/admin/users_live_test.exs new file mode 100644 index 0000000..414fa03 --- /dev/null +++ b/test/dexi_web/live/admin/users_live_test.exs @@ -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 diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..3ad6742 --- /dev/null +++ b/test/support/conn_case.ex @@ -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 diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..ea4b463 --- /dev/null +++ b/test/support/data_case.ex @@ -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 diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..3574985 --- /dev/null +++ b/test/support/fixtures/accounts_fixtures.ex @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..3b341f8 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Dexi.Repo, :manual)