Merge pull request 'Fetch/store Web App metadata and icons, finish RS integration' (#153) from feature/142-webapp_database into master

Reviewed-on: #153
Reviewed-by: galfert <garret.alfert@gmail.com>
This commit was merged in pull request #153.
This commit is contained in:
Râu Cao
2024-01-01 13:18:47 +00:00
57 changed files with 1113 additions and 382 deletions

View File

@@ -0,0 +1,26 @@
<div data-controller="dropdown" data-action="click->dropdown#toggle click@window->dropdown#hide">
<div class="relative inline-block">
<div role="button" tabindex="0" data-dropdown-target="button"
class="inline-block select-none">
<span class="appearance-none flex items-center inline-block">
<span class="p-2 bg-gray-50 hover:bg-gray-100 rounded-full">
<%= render partial: "icons/kebab-menu", locals: {
custom_class: "inline text-gray-500 h-6 w-6"
} %>
</span>
</span>
</div>
<div data-dropdown-target="menu"
data-transition-enter="transition ease-out duration-200"
data-transition-enter-from="opacity-0 translate-y-1"
data-transition-enter-to="opacity-100 translate-y-0"
data-transition-leave="transition ease-in duration-150"
data-transition-leave-from="opacity-100 translate-y-0"
data-transition-leave-to="opacity-0 translate-y-1"
class="hidden absolute top-4 right-0 z-10 mt-5 flex w-screen max-w-max">
<div class="bg-white shadow-lg rounded border overflow-hidden w-auto">
<%= content %>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
class DropdownComponent < ViewComponent::Base
end

View File

@@ -0,0 +1,6 @@
<%= link_to @href, class: @class, data: {
'dropdown-target': "menuItem",
'action': "keydown.up->dropdown#previousItem:prevent keydown.down->dropdown#nextItem:prevent"
} do %>
<%= content %>
<% end %>

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
class DropdownLinkComponent < ViewComponent::Base
def initialize(href:, separator: false, add_class: nil)
@href = href
@class = class_str(separator, add_class)
end
private
def class_str(separator, add_class)
str = "no-underline block px-5 py-3 text-sm text-gray-900 bg-white
hover:bg-gray-100 focus:bg-gray-100 whitespace-no-wrap"
str = "#{str} border-t" if separator
str = "#{str} #{add_class}" if add_class
str
end
end

View File

@@ -0,0 +1,26 @@
<div class="flex items-center gap-4">
<div class="h-16 w-16 flex-none">
<%= image_tag s3_image_url(@web_app.icon), class: "h-full w-full" %>
</div>
<div class="flex-grow">
<h4 class="mb-1 text-lg font-bold">
<%= @web_app.name %>
</h4>
<p class="text-sm text-gray-500">
<%= @auth.client_id %>
</p>
</div>
<%= render DropdownComponent.new do %>
<%= render DropdownLinkComponent.new(
href: launch_app_services_storage_rs_auth_url(@auth)
) 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 %>
</div>

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class RsAuthComponent < ViewComponent::Base
def initialize(auth:)
@auth = auth
@web_app = auth.web_app
end
end

View File

@@ -0,0 +1,9 @@
class Admin::AppCatalog::WebAppsController < Admin::AppCatalogController
def index
@pagy, @web_apps = pagy(AppCatalog::WebApp.order('created_at desc'))
@stats = {
known_apps: AppCatalog::WebApp.count
}
end
end

View File

@@ -0,0 +1,9 @@
class Admin::AppCatalogController < Admin::BaseController
before_action :set_current_section
private
def set_current_section
@current_section = :app_catalog
end
end

View File

@@ -3,8 +3,7 @@ class Rs::OauthController < ApplicationController
before_action :authenticate_user!, only: :create
def new
username, org = params[:useraddress].split("@")
@user = User.where(cn: username.downcase, ou: org).first
@user = User.where(cn: params[:username].downcase, ou: Setting.primary_domain).first
@scopes = parse_scopes params[:scope]
@redirect_uri = params[:redirect_uri]
@client_id = params[:client_id]
@@ -22,7 +21,7 @@ class Rs::OauthController < ApplicationController
unless current_user == @user
sign_out :user
redirect_to new_rs_oauth_url(@user.address,
redirect_to new_rs_oauth_url(@user.cn,
scope: params[:scope],
redirect_uri: params[:redirect_uri],
client_id: params[:client_id],
@@ -88,7 +87,7 @@ class Rs::OauthController < ApplicationController
permissions: permissions,
client_id: client_id,
redirect_uri: redirect_uri,
app_name: client_id, #TODO use user-defined name
app_name: client_id,
expire_at: expire_at
)
@@ -96,29 +95,15 @@ class Rs::OauthController < ApplicationController
allow_other_host: true
end
# GET /rs/oauth/token/:id/launch_app
def launch_app
auth = current_user.remote_storage_authorizations.find(params[:id])
redirect_to app_auth_url(auth), allow_other_host: true
end
private
def require_signed_in_with_username
unless user_signed_in?
username, org = params[:useraddress].split("@")
session[:user_return_to] = request.url
redirect_to new_user_session_path(cn: username, ou: org)
redirect_to new_user_session_path(cn: params[:username], ou: Setting.primary_domain)
end
end
def app_auth_url(auth)
url = "#{auth.url}#remotestorage=#{current_user.address}"
url += "&access_token=#{auth.token}"
url
end
def hostname_of(uri)
uri.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0]
end

View File

@@ -3,10 +3,13 @@ class Services::RemotestorageController < Services::BaseController
before_action :require_feature_enabled
before_action :require_service_available
def dashboard
# Dashboard
def show
# unless current_user.services_enabled.include?(:remotestorage)
# redirect_to service_remotestorage_info_path
# end
@rs_auths = current_user.remote_storage_authorizations
# TODO sort by app name
end
private

View File

@@ -0,0 +1,42 @@
class Services::RsAuthsController < Services::BaseController
before_action :authenticate_user!
before_action :require_feature_enabled
before_action :require_service_available
# before_action :require_service_enabled
before_action :find_rs_auth
def destroy
@auth.destroy!
respond_to do |format|
format.html do redirect_to services_storage_url, flash: {
success: 'App authorization revoked'
}
end
format.json { head :no_content }
end
end
def launch_app
launch_url = "#{@auth.launch_url}#remotestorage=#{current_user.address}&access_token=#{@auth.token}"
redirect_to launch_url, allow_other_host: true
end
private
def require_feature_enabled
unless Flipper.enabled?(:remotestorage, current_user)
http_status :forbidden
end
end
def require_service_available
http_status :not_found unless Setting.remotestorage_enabled?
end
def find_rs_auth
@auth = current_user.remote_storage_authorizations.find(params[:id])
http_status :not_found unless @auth.present?
end
end

View File

@@ -110,7 +110,9 @@ class SettingsController < ApplicationController
def set_settings_section
@settings_section = params[:section]
allowed_sections = [:profile, :account, :lightning, :xmpp, :experiments]
allowed_sections = [
:profile, :account, :lightning, :remotestorage, :xmpp, :experiments
]
unless allowed_sections.include?(@settings_section.to_sym)
redirect_to setting_path(:profile)
@@ -124,6 +126,7 @@ class SettingsController < ApplicationController
def user_params
params.require(:user).permit(:display_name, :avatar, preferences: [
:lightning_notify_sats_received,
:remotestorage_notify_auth_created,
:xmpp_exchange_contacts_with_invitees
])
end

View File

@@ -6,15 +6,19 @@ class WebfingerController < ApplicationController
def show
resource = params[:resource]
if resource && resource.match(/acct:\w+/)
useraddress = resource.split(":").last
username, org = useraddress.split("@")
username.downcase!
unless User.where(cn: username, ou: org).any?
if resource && @useraddress = resource.match(/acct:(.+)/)&.[](1)
@username, @org = @useraddress.split("@")
unless Rails.env.development?
# Allow different domains (e.g. localhost:3000) in development only
head 404 and return unless @org == Setting.primary_domain
end
unless User.where(cn: @username.downcase, ou: Setting.primary_domain).any?
head 404 and return
end
render json: webfinger(useraddress).to_json,
render json: webfinger.to_json,
content_type: "application/jrd+json"
else
head 422 and return
@@ -23,19 +27,18 @@ class WebfingerController < ApplicationController
private
def webfinger(useraddress)
def webfinger
links = [];
links << remotestorage_link(useraddress) if Setting.remotestorage_enabled
# TODO check if storage service is enabled for user, not just globally
links << remotestorage_link if Setting.remotestorage_enabled
{ "links" => links }
end
def remotestorage_link(useraddress)
# TODO use when OAuth routes are available
# auth_url = new_rs_oauth_url(useraddress)
auth_url = "https://example.com/rs/oauth"
storage_url = "#{Setting.rs_storage_url}/#{useraddress}"
def remotestorage_link
auth_url = new_rs_oauth_url(@username)
storage_url = "#{Setting.rs_storage_url}/#{@username}"
{
"rel" => "http://tools.ietf.org/id/draft-dejong-remotestorage",

View File

@@ -1,8 +1,9 @@
import { Application } from "@hotwired/stimulus"
import { Modal, Tabs } from "tailwindcss-stimulus-components"
import { Dropdown, Modal, Tabs } from "tailwindcss-stimulus-components"
const application = Application.start()
application.register('dropdown', Dropdown)
application.register('modal', Modal)
application.register('tabs', Tabs)

View File

@@ -5,4 +5,16 @@ class NotificationMailer < ApplicationMailer
@subject = "Sats received"
mail to: @user.email, subject: @subject
end
def remotestorage_auth_created
@user = params[:user]
@auth = params[:auth]
@permissions = @auth.permissions.map do |p|
access = p.split(":")[1] == 'r' ? 'read' : 'read/write'
directory = p.split(':')[0] == '' ? 'all folders and files' : p.split(':')[0]
"#{access} #{directory}"
end
@subject = "New app connected to your storage"
mail to: @user.email, subject: @subject
end
end

View File

@@ -0,0 +1,5 @@
module AppCatalog
def self.table_name_prefix
"app_catalog_"
end
end

View File

@@ -0,0 +1,16 @@
class AppCatalog::WebApp < ApplicationRecord
store :metadata, coder: JSON
has_many :remote_storage_authorizations
has_one_attached :icon
has_one_attached :apple_touch_icon
validates :url, presence: true, uniqueness: true
validates :url, format: { with: URI.regexp },
if: Proc.new { |a| a.url.present? }
def update_metadata
AppCatalogManager::UpdateMetadata.call(self)
end
end

View File

@@ -1,5 +1,6 @@
class RemoteStorageAuthorization < ApplicationRecord
belongs_to :user
belongs_to :web_app, class_name: "AppCatalog::WebApp", optional: true
serialize :permissions
@@ -15,22 +16,36 @@ class RemoteStorageAuthorization < ApplicationRecord
before_create :generate_token
before_create :store_token_in_redis
before_create :find_or_create_web_app
after_create :schedule_token_expiry
after_create :notify_user
before_destroy :delete_token_from_redis
after_destroy :remove_token_expiry_job
def url
if self.redirect_uri
uri = URI.parse self.redirect_uri
"#{uri.scheme}://#{client_id}"
uri = URI.parse self.redirect_uri
"#{uri.scheme}://#{client_id}"
end
def launch_url
return url unless web_app && web_app.metadata[:start_url].present?
start_url = web_app.metadata[:start_url]
if start_url.match("^https?:\/\/")
return start_url.start_with?(url) ? start_url : url
else
"http://#{client_id}"
path = start_url.gsub(/^\.\.\//, "").gsub(/^\.\//, "").gsub(/^\//, "")
"#{url}/#{path}"
end
end
def delete_token_from_redis
key = "rs:authorizations:#{user.address}:#{token}"
key = "authorizations:#{user.cn}:#{token}"
redis.srem? key, redis.smembers(key)
rescue => e
Rails.logger.error e
Sentry.capture_exception(e) if Setting.sentry_enabled?
end
private
@@ -44,7 +59,7 @@ class RemoteStorageAuthorization < ApplicationRecord
end
def store_token_in_redis
redis.sadd "rs:authorizations:#{user.address}:#{token}", permissions
redis.sadd "authorizations:#{user.cn}:#{token}", permissions
end
def schedule_token_expiry
@@ -60,4 +75,40 @@ class RemoteStorageAuthorization < ApplicationRecord
job.delete if job.display_args == [id]
end
end
def find_or_create_web_app
if looks_like_hosted_origin?
web_app = AppCatalog::WebApp.find_or_create_by!(url: self.url)
web_app.update_metadata unless web_app.name.present?
self.web_app = web_app
self.app_name = web_app.name.presence || client_id
else
self.app_name = client_id
end
end
def looks_like_hosted_origin?
uri = URI.parse self.redirect_uri
!!(uri.host =~ /(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]\.)+[a-zA-Z]{2,63}$)/)
rescue URI::InvalidURIError
false
end
def notify_user
notify = user.preferences[:remotestorage_notify_auth_created]
case notify
when "xmpp"
router = Router.new
payload = {
type: "normal", to: user.address,
from: Setting.xmpp_notifications_from_address,
body: "You have just granted '#{self.client_id}' access to your Kosmos Storage. Visit your Storage dashboard to check on your connected apps and revoke permissions anytime: #{router.services_storage_url}"
}
XmppSendMessageJob.perform_later(payload)
when "email"
NotificationMailer.with(user: user, auth: self)
.remotestorage_auth_created.deliver_later
end
end
end

View File

@@ -0,0 +1,52 @@
require "manifique"
require "down"
module AppCatalogManager
class UpdateMetadata < AppCatalogManagerService
def initialize(app)
@app = app
end
def call
agent = Manifique::Agent.new(url: @app.url)
metadata = agent.fetch_metadata
@app.name = metadata.name
[:name, :short_name, :description, :theme_color, :background_color,
:display, :start_url, :scope, :share_target, :icons].each do |prop|
@app.metadata[prop] = metadata.send(prop) if prop
end
if icon = metadata.select_icon(sizes: "256x256") ||
icon = metadata.select_icon(sizes: "192x192")
attach_remote_image(:icon, icon)
# TODO elsif get whatever is available
end
if apple_touch_icon = metadata.select_icon(purpose: "apple-touch-icon")
attach_remote_image(:apple_touch_icon, apple_touch_icon)
end
@app.save!
rescue Manifique::Error => e
msg = "Fetching web app manifest failed for #{e.url}: #{e.type}"
Rails.logger.warn(msg)
Sentry.capture_message(msg) if Setting.sentry_enabled?
false
end
def attach_remote_image(attachment_name, icon)
if icon['src'].start_with?("http")
download_url = icon['src']
else
download_url = "#{@app.url}/#{icon["src"].gsub(/^\//,'')}"
end
filename = "#{attachment_name}.png"
key = "web_apps/#{@app.id}/icons/#{attachment_name}.png"
tempfile = Down.download(download_url)
@app.send(attachment_name).attach(key: key, io: tempfile, filename: filename)
end
end
end

View File

@@ -0,0 +1,2 @@
class AppCatalogManagerService < ApplicationService
end

7
app/services/router.rb Normal file
View File

@@ -0,0 +1,7 @@
class Router
include Rails.application.routes.url_helpers
def self.default_url_options
ActionMailer::Base.default_url_options
end
end

View File

@@ -0,0 +1,56 @@
<%= render HeaderComponent.new(title: "App Catalog") %>
<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/admin_sidenav_app_catalog') do %>
<section>
<%= render QuickstatsContainerComponent.new do %>
<%= render QuickstatsItemComponent.new(
type: :number,
title: 'Known Web Apps',
value: @stats[:known_apps],
) %>
<%# <%= render QuickstatsItemComponent.new(
<%# type: :number,
<%# title: 'Accepted',
<%# value: @stats[:accepted],
<%# ) %>
<%# <%= render QuickstatsItemComponent.new(
<%# type: :number,
<%# title: 'Users with referrals',
<%# value: @stats[:users_with_referrals],
<%# meta: "/ #{User.count}"
<%# ) %>
<% end %>
</section>
<% if @web_apps.any? %>
<section>
<h3>Web Apps</h3>
<table class="divided mb-8">
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th class="hidden md:table-cell">RS Auths</th>
<th class="hidden md:table-cell">Created at</th>
</tr>
</thead>
<tbody>
<% @web_apps.each do |web_app| %>
<tr>
<td><%= web_app.name %></td>
<td><%= link_to web_app.url, web_app.url,
target: "_blank", rel: "nofollow noopener",
class: "ks-text-link" %></td>
<td class="hidden md:table-cell"><%= web_app.remote_storage_authorizations.count %></td>
<td class="hidden md:table-cell">
<span title="<%= web_app.created_at %>" class="cursor-help">
<%= time_ago_in_words web_app.created_at, include_seconds: false %> ago
</span>
</td>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_nav @pagy %>
</section>
<% end %>
<% end %>

View File

@@ -63,10 +63,12 @@
</section>
<section class="sm:flex-1 sm:pt-0">
<% if @avatar.present? %>
<h3>LDAP<h3>
<p>
<img src="data:image/jpeg;base64,<%= @avatar %>" class="h-48 w-48" />
</p>
<% end %>
<!-- <h3>Actions</h3> -->
</section>
</div>

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-globe"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-globe <%= custom_class %>"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>

Before

Width:  |  Height:  |  Size: 409 B

After

Width:  |  Height:  |  Size: 430 B

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" class="<%= custom_class %>" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Menu</title>
<g id="kebap-menu" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<rect id="Container" x="0" y="0" width="24" height="24"></rect>
<path d="M12,6 C12.5522847,6 13,5.55228475 13,5 C13,4.44771525 12.5522847,4 12,4 C11.4477153,4 11,4.44771525 11,5 C11,5.55228475 11.4477153,6 12,6 Z" stroke="#030819" stroke-width="2" stroke-linecap="round" stroke-dasharray="0,0"></path>
<path d="M12,13 C12.5522847,13 13,12.5522847 13,12 C13,11.4477153 12.5522847,11 12,11 C11.4477153,11 11,11.4477153 11,12 C11,12.5522847 11.4477153,13 12,13 Z" stroke="#030819" stroke-width="2" stroke-linecap="round" stroke-dasharray="0,0"></path>
<path d="M12,20 C12.5522847,20 13,19.5522847 13,19 C13,18.4477153 12.5522847,18 12,18 C11.4477153,18 11,18.4477153 11,19 C11,19.5522847 11.4477153,20 12,20 Z" stroke="#030819" stroke-width="2" stroke-linecap="round" stroke-dasharray="0,0"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" class="<%= custom_class %>" clip-rule="evenodd" fill-rule="evenodd" image-rendering="optimizeQuality" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" version="1.1" viewBox="0 0 250 249.9" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-66.822 -.16484)">
<polygon id="polygon1" fill="currentColor" transform="matrix(.29308 0 0 .29308 83.528 -.028385)" points="228 181 370 100 511 181 652 263 370 425 87 263 87 263 0 213 0 213 0 311 0 378 0 427 0 476 86 525 185 582 370 689 554 582 653 525 653 590 653 592 370 754 0 542 0 640 185 747 370 853 554 747 739 640 739 525 739 476 739 427 739 378 653 427 370 589 86 427 86 361 185 418 370 524 554 418 653 361 739 311 739 213 554 107 370 0 185 107 58 180 144 230"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 848 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star <%= custom_class %>"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 360 B

View File

@@ -0,0 +1,23 @@
Hi <%= @user.display_name.presence || @user.cn %>,
You have just granted '<%= @auth.client_id %>' access to your Kosmos Storage, with the following permissions:
<% @permissions.each do |p| %>
* <%= p %>
<% end %>
Visit your Storage dashboard to check on your connected apps and revoke permissions anytime:
<%= services_storage_url %>
Have fun!
---
You can disable email notifications for new app authorizations in your account settings:
<%= setting_url(:remotestorage) %>
<% if Setting.discourse_enabled %>
If you have any questions, please visit our community forums:
<%= Setting.discourse_public_url %>
<% end %>

View File

@@ -38,7 +38,7 @@
<h3>Chat Apps</h3>
<p>
Use your account with many different apps, and on any devices you wish!
When opening an app for the first time, just enter your user address and
When opening an app for the first time, just enter your address and
password to log in.
</p>
</section>

View File

@@ -1,7 +0,0 @@
<%= render HeaderComponent.new(title: "Storage") %>
<%= render MainSimpleComponent.new do %>
<section>
<h3>Feature enabled</h3>
</section>
<% end %>

View File

@@ -0,0 +1,16 @@
<%= render HeaderComponent.new(title: "Storage") %>
<%= render MainSimpleComponent.new do %>
<section>
<h3 class="mb-10">Connected Apps</h3>
<% if @rs_auths.any? %>
<div class="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-y-10 gap-x-12">
<% @rs_auths.each do |auth| %>
<%= render RsAuthComponent.new(auth: auth) %>
<% end %>
</div>
<% else %>
<p>No apps connected yet.</p>
<% end %>
</section>
<% end %>

View File

@@ -0,0 +1,25 @@
<%= form_for @user, url: setting_path(:remotestorage), html: { :method => :put } do |f| %>
<section>
<h3>Notifications</h3>
<ul role="list">
<%= render FormElements::FieldsetComponent.new(
positioning: :horizontal,
title: "New connection authorized",
description: "Notify me when my storage is connected to a new app"
) do %>
<% f.fields_for :preferences do |p| %>
<%= p.select :remotestorage_notify_auth_created, options_for_select([
["off", "disabled"],
["Chat (Jabber)", "xmpp"], # TODO make DRY, check for XMPP enabled
["E-Mail", "email"]
], selected: @user.preferences[:remotestorage_notify_auth_created]) %>
<% end %>
<% end %>
</ul>
</section>
<section>
<p class="pt-6 border-t border-gray-200 text-right">
<%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %>
</p>
</section>
<% end %>

View File

@@ -10,5 +10,9 @@
<%= link_to "Lightning", admin_lightning_path,
class: main_nav_class(@current_section, :lightning) %>
<% end %>
<% if Setting.remotestorage_enabled? %>
<%= link_to "Apps", admin_app_catalog_web_apps_path,
class: main_nav_class(@current_section, :app_catalog) %>
<% end %>
<%= link_to "Settings", admin_settings_registrations_path,
class: main_nav_class(@current_section, :settings) %>

View File

@@ -0,0 +1,10 @@
<%= render SidenavLinkComponent.new(
name: "Web Apps", path: admin_app_catalog_web_apps_path, icon: "globe",
active: current_page?(admin_app_catalog_web_apps_path)
) %>
<%= render SidenavLinkComponent.new(
name: "Recommended Apps", path: "#", icon: "star", disabled: true
) %>
<%= render SidenavLinkComponent.new(
name: "OAuth Apps", path: "#", icon: "key", disabled: true
) %>

View File

@@ -18,6 +18,12 @@
active: @settings_section.to_s == "lightning"
) %>
<% end %>
<% if Setting.remotestorage_enabled %>
<%= render SidenavLinkComponent.new(
name: "Storage", path: setting_path(:remotestorage), icon: "remotestorage",
active: @settings_section.to_s == "remotestorage"
) %>
<% end %>
<% if Setting.nostr_enabled %>
<%= render SidenavLinkComponent.new(
name: "Experiments", path: setting_path(:experiments), icon: "science",