diff --git a/AGENTS.md b/AGENTS.md index 13e4939..6bf026e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,13 +7,16 @@ ejabberd, Discourse, Mastodon, remoteStorage, Nostr, LNDHub, and BTCPay. ## Development environment Development runs in Docker Compose — run all commands against the `web` container. -Start services: `docker compose up` (web, ldap, redis, minio, liquor-cabinet, strfry). +Start services: `docker compose up` (web, ldap, redis, garage, liquor-cabinet, strfry). The `web` service runs `bin/dev` (foreman: Puma + Tailwind CSS watcher) and embeds Solid Queue workers (`SOLID_QUEUE_IN_PUMA=true`). -First-time LDAP setup (after creating the 389ds backend once): -`docker compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be_name="dev"` -then `docker compose run web bin/rails ldap:setup`. +First-time LDAP and database setup is automated: the `ldap-init` service +creates the 389ds back-end, then the `web` entrypoint seeds LDAP and the +databases on first start and runs `db:prepare` on every boot. Manual +equivalents: `docker compose exec ldap dsconf localhost backend create +--suffix="dc=kosmos,dc=org" --be-name="dev"` and `docker compose run --rm web +bin/rails ldap:setup`. ## Common commands (prefix with `docker compose exec web`) diff --git a/Gemfile b/Gemfile index 5ec961f..e41a186 100644 --- a/Gemfile +++ b/Gemfile @@ -43,6 +43,7 @@ gem 'flipper-ui' gem 'gpgme', '~> 2.0.24' gem 'zbase32', '~> 0.1.1' gem 'kramdown' +gem 'device_detector' # HTTP requests gem 'faraday' diff --git a/Gemfile.lock b/Gemfile.lock index 07a76dc..9838525 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -136,6 +136,7 @@ GEM database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) date (3.5.1) + device_detector (1.1.3) devise (5.0.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -543,6 +544,7 @@ DEPENDENCIES capybara cssbundling-rails database_cleaner + device_detector devise (~> 5.0) devise_ldap_authenticatable discourse_api diff --git a/README.md b/README.md index e20a535..0888d00 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,14 @@ so: 1. Make sure [Docker Compose is installed][1] and Docker is running (included in Docker Desktop) -3. Run `docker compose up --build` and wait until all services have started +2. Run `docker compose up --build` and wait until all services have started (389ds might take an extra minute to be ready). This will take a while when running for the first time, so you might want to do something else in the meantime. -4. `docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"` -5. `docker compose run web rails ldap:setup` -6. `docker compose run web rails db:setup` + +On the first start, the `ldap-init` service creates the 389ds back-end, and the +`web` container then seeds the LDAP directory and the databases automatically. +On every start, `web` also applies any pending database migrations. After these steps, you should have a working Rails app with a handful of test users running on [http://localhost:3000](http://localhost:3000). @@ -71,15 +72,12 @@ containers you want to run to the `up` command, like so: #### LDAP server -After creating the Docker container for the first time (or after deleting it), -you need to run the following command once, in order to create the dirsrv -back-end: +On first start, the `ldap-init` service creates the dirsrv back-end +automatically, and the `web` container then seeds it with development entries. +To do either step manually (for example, after changing the setup), run: - docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev" - -Now you can seed the back-end with data using this Rails task: - - bundle exec rails ldap:setup + docker compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev" + docker compose run --rm web bin/rails ldap:setup The setup task will first delete any existing entries in the directory tree ("dc=kosmos,dc=org"), and then create our development entries. @@ -88,20 +86,42 @@ Note that all 389ds data is stored in the `389ds-data` volume. So if you want to start over with a fresh installation, delete both that volume as well as the container. -#### Minio / remoteStorage +To reset the development environment completely, remove all volumes plus the +generated database files and the first-run marker, then start over: -If you want to run remoteStorage accounts locally, you will have to create the -respective bucket first. With the `minio` container running (run by default -when using Docker Compose), follow these steps: + docker compose down -v + rm -f db/*.sqlite3 tmp/.setup-complete + docker compose up --build -* `docker compose up web redis minio liquor-cabinet` -* Head to http://localhost:9001 and log in with user `minioadmin`, password - `minioadmin` -* Create a new bucket called `remotestorage` (or whatever you - change the `S3_BUCKET` config to) -* Create a new key with ID "dev-key" and secret "123456789" (or whatever you - change `S3_ACCESS_KEY` and `S3_SECRET_KEY` to). Leave the policy field empty, - as it will automatically allow access to the bucket you created. +#### Garage / remoteStorage + +remoteStorage accounts use the `garage` S3-compatible object store. On first +start, Garage automatically configures a single-node cluster and creates the +`remotestorage` bucket together with a `dev-key1` access key (secret +`1234567890123456`), so no manual setup is required. + +If you want to run remoteStorage accounts locally, the `garage` container is +started by default when using Docker Compose. To run just the remoteStorage +stack: + +* `docker compose up web redis garage liquor-cabinet` + +The S3 API is available at http://localhost:3900 (region `garage`). If you want +to start over with a fresh storage, delete the `garage-data` volume as well as +the container. + +#### Accessing remoteStorage from another machine + +remoteStorage clients force HTTPS for any host except `localhost`, and browsers +block plain-HTTP requests to a LAN IP as mixed content. To connect to the dev +remoteStorage from a browser on another machine (including production apps such +as Inspektor), forward the ports over SSH and connect as `localhost`: + + ssh -N -L 3000:localhost:3000 -L 4567:localhost:4567 @ + +Then use `@localhost:3000` as the remoteStorage address in the client. +WeFinger discovery and storage requests are served through the forwarded ports, +so no TLS setup is needed. ### Adding npm modules to use with Stimulus controllers diff --git a/app/assets/stylesheets/components/animations.css b/app/assets/stylesheets/components/animations.css index 23b7664..315ab7f 100644 --- a/app/assets/stylesheets/components/animations.css +++ b/app/assets/stylesheets/components/animations.css @@ -14,3 +14,27 @@ animation-duration: 0.15s; animation-timing-function: cubic-bezier(0.2, 0, 0.13, 1); } + +@keyframes removeRow { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(0.75rem); + } +} + +.animate-remove { + animation: removeRow 0.2s ease-out forwards; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + /* Keep the animation, so the deferred removal still completes, but make it + effectively instant. */ + .animate-remove { + animation-duration: 0.01s; + } +} diff --git a/app/components/app_catalog/web_app_icon_component.html.erb b/app/components/app_catalog/web_app_icon_component.html.erb index 3f9c5f3..37af872 100644 --- a/app/components/app_catalog/web_app_icon_component.html.erb +++ b/app/components/app_catalog/web_app_icon_component.html.erb @@ -1,5 +1,7 @@ <% if @image_url %> - <%= image_tag @image_url, class: "h-full w-full" %> + <%= image_tag @image_url, class: "h-full w-full rounded-lg object-contain" %> <% else %> - <%= render partial: "icons/remotestorage", locals: { custom_class: "h-full w-full p-0.5 text-gray-200" } %> + + <%= render partial: "icons/remotestorage", locals: { custom_class: "h-1/2 w-1/2 text-gray-400" } %> + <% end %> diff --git a/app/components/rs_auth_access_component.html.erb b/app/components/rs_auth_access_component.html.erb new file mode 100644 index 0000000..cc50b66 --- /dev/null +++ b/app/components/rs_auth_access_component.html.erb @@ -0,0 +1,7 @@ +
+
+ Access +
+ <%= render RsScopeListComponent.new(scopes: permissions, root: root_access?, + icon_class: "h-5 w-5") %> +
\ No newline at end of file diff --git a/app/components/rs_auth_access_component.rb b/app/components/rs_auth_access_component.rb new file mode 100644 index 0000000..605d742 --- /dev/null +++ b/app/components/rs_auth_access_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class RsAuthAccessComponent < ViewComponent::Base + include RsAuthSummary + include RsAuthsHelper + + def initialize(authorizations:) + @authorizations = authorizations + @auth = authorizations.first + end + + def access_id + rs_app_dom_id(@auth.client_id, :access) + end +end \ No newline at end of file diff --git a/app/components/rs_auth_card_component.html.erb b/app/components/rs_auth_card_component.html.erb new file mode 100644 index 0000000..3ff311e --- /dev/null +++ b/app/components/rs_auth_card_component.html.erb @@ -0,0 +1,35 @@ + \ No newline at end of file diff --git a/app/components/rs_auth_card_component.rb b/app/components/rs_auth_card_component.rb new file mode 100644 index 0000000..ea8e7f1 --- /dev/null +++ b/app/components/rs_auth_card_component.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class RsAuthCardComponent < ViewComponent::Base + include RsAuthSummary + include RsAuthsHelper + + def initialize(authorizations:) + @authorizations = authorizations + @auth = authorizations.first + @web_app = @auth.web_app + end + + def card_id + rs_app_dom_id(@auth.client_id, :card) + end +end \ No newline at end of file diff --git a/app/components/rs_auth_component.html.erb b/app/components/rs_auth_component.html.erb index a26d35d..2244194 100644 --- a/app/components/rs_auth_component.html.erb +++ b/app/components/rs_auth_component.html.erb @@ -1,27 +1,4 @@ -
-
- <%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %> -
-
-

- <%= @web_app&.name || @auth.app_name %> -

-

- <%= @auth.client_id %> -

-
- <%= render DropdownComponent.new do %> - <%= render DropdownLinkComponent.new( - href: launch_app_services_storage_rs_auth_url(@auth), - open_in_new_tab: true - ) do %> - Launch app - <% end %> - <%= render DropdownLinkComponent.new( - href: revoke_services_storage_rs_auth_url(@auth), - separator: true, add_class: "text-red-700" - ) do %> - Revoke access - <% end %> - <% end %> -
+
+ <%= render RsAuthCardComponent.new(authorizations: @authorizations) %> + <%= render RsAuthDetailsModalComponent.new(authorizations: @authorizations) %> +
\ No newline at end of file diff --git a/app/components/rs_auth_component.rb b/app/components/rs_auth_component.rb index ed588bf..cb29f0c 100644 --- a/app/components/rs_auth_component.rb +++ b/app/components/rs_auth_component.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true +# Wrapper around one app's card and its details modal. The modal's open state +# lives on this element (via the `modal` controller), so the card and the modal +# can be re-rendered independently without closing it. class RsAuthComponent < ViewComponent::Base - def initialize(auth:) - @auth = auth - @web_app = auth.web_app + def initialize(authorizations:) + @authorizations = authorizations end -end +end \ No newline at end of file diff --git a/app/components/rs_auth_details_modal_component.html.erb b/app/components/rs_auth_details_modal_component.html.erb new file mode 100644 index 0000000..9c0c15f --- /dev/null +++ b/app/components/rs_auth_details_modal_component.html.erb @@ -0,0 +1,52 @@ +<%= render ModalComponent.new(show_close_button: false) do %> +
+
+ <%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %> +
+
+

<%= @auth.display_name %>

+ <% if @auth.client_id != @auth.display_name %> +

<%= @auth.client_id %>

+ <% end %> +
+
+ + <%= render RsAuthAccessComponent.new(authorizations: @authorizations) %> + +
+ Connected devices +
+
    + <% @authorizations.each do |auth| %> +
  • +
    +

    <%= auth.device_label %>

    +

    + Connected <%= timestamp_tag(auth.created_at, title: connected_at_title(auth)) %> + · + <%= tag.span class: ("text-red-700" if auth.expired?) do %> + <% if auth.expire_at.present? %> + <%= expiry_prefix(auth) %> <%= timestamp_tag(auth.expire_at, title: expires_at_title(auth)) %> + <% else %> + Never expires + <% end %> + <% end %> +

    +
    + <%= button_to "Revoke access", services_storage_rs_auth_path(auth), method: :delete, + form: { class: "flex-none" }, + class: "whitespace-nowrap text-sm text-red-700 hover:underline bg-transparent border-0 p-0 cursor-pointer" %> +
  • + <% end %> +
+ +
+ <%= link_to "Launch app", launch_app_services_storage_path(@auth.client_id), + class: "btn-md btn-blue w-full sm:grow sm:order-last text-center", + target: "_blank", rel: "noopener", data: { turbo: false } %> + +
+<% end %> \ No newline at end of file diff --git a/app/components/rs_auth_details_modal_component.rb b/app/components/rs_auth_details_modal_component.rb new file mode 100644 index 0000000..90aef7f --- /dev/null +++ b/app/components/rs_auth_details_modal_component.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class RsAuthDetailsModalComponent < ViewComponent::Base + include RsAuthsHelper + + def initialize(authorizations:) + @authorizations = authorizations + @auth = authorizations.first + @web_app = @auth.web_app + end + + # "Expires" / "Expired on", or nothing when there is no expiry to describe. + def expiry_prefix(auth) + return nil if auth.expire_at.blank? + + auth.expired? ? "Expired on" : "Expires" + end + + # Fallbacks for the hover titles, used when JavaScript is unavailable. + # app/javascript/local_time.js replaces them with the reader's own zone. + def connected_at_title(auth) + auth.created_at.strftime("%-d %b %Y, %H:%M") + end + + def expires_at_title(auth) + auth.expire_at.strftime("%-d %b %Y, %H:%M") + end + + # A