tiny_admin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: afea7b433e47bdf47e2682a334e2ffe7761e6e341aec9b43746291faf4d16406
4
+ data.tar.gz: 6c53fbae6a6c6749ef423bbd190094c25149abae29c6c235bf49f13fb3d6906a
5
+ SHA512:
6
+ metadata.gz: 4b9701bccac2aa19c6fb838c037e0522d6ee8d3560ec1d5aa743725a64d1c2e4bfc7369faaef9bdc6b98da280f8f1d82c302fadfae5620fe1e958857238005c4
7
+ data.tar.gz: 16763a9cdf6df77cc261f5a5493803a55cd41756888026fb93ccdf66bf7c6fe7412482b39a666fd026892787addeb55c80adef45b1af479b0f062eb7276aaa53
data/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2023 Mattia Roccoberton
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # Tiny Admin
2
+
3
+ A compact and composible dashboard component for Ruby.
4
+
5
+ The main features are:
6
+ - a Rack app that can be mounted in any Rack-enabled framework;
7
+ - some features are handled as plugins, so they can be replaced with little effort;
8
+ - routing is provided by Roda (which is small and performant);
9
+ - views are Phlex components.
10
+
11
+ See (extra)[extra] folder for usage examples.
12
+
13
+ Please ⭐ if you like it.
14
+
15
+ ## Install
16
+
17
+ - Add to your Gemfile: `gem 'tiny_admin'`
18
+ - For a Rails project: add an initializer and the YAML config - see (configuration)[#configuration] below.
19
+
20
+ ## Plugins and components
21
+
22
+ Every plugin or component can be replaced.
23
+
24
+ ### Authentication
25
+
26
+ There are 2 plugins included:
27
+ - _SimpleAuth_: provides a simple session authentication based on Warden (`warden` gem must be included in the host project);
28
+ - _NoAuth_: no authentication.
29
+
30
+ ### Repository
31
+
32
+ There is 1 plugin included:
33
+ - _ActiveRecordRepository_: isolates the query layer to expose the resources in the admin interface.
34
+
35
+ ### View pages
36
+
37
+ There are 5 view pages included:
38
+ - _Root_: define how to present the content in the main page of the interface;
39
+ - _PageNotFound_: define how to present pages not found;
40
+ - _RecordNotFound_: define how to present record not found page;
41
+ - _SimpleAuthLogin_: define how to present the login form for SimpleAuth plugin;
42
+ - _Index_: define how to present a collection of items;
43
+ - _Show_: define how to present the details of an item.
44
+
45
+ ### View components
46
+
47
+ There are 5 view components included:
48
+ - _FiltersForm_: define how to present the filters form in the resource collection pages;
49
+ - _Flash_: define how to present the flash messages;
50
+ - _Head_: define how to present the Head tag;
51
+ - _Navbar_: define how to present the navbar (the default one uses the Bootstrap structure);
52
+ - _Pagination_: define how to present the pagination of a collection.
53
+
54
+ ## Configuration
55
+
56
+ TinyAdmin can be configured programmatically or using a YAML config.
57
+
58
+ Example:
59
+
60
+ ```rb
61
+ # config/initializers/tiny_admin.rb
62
+
63
+ # hash generated using: Digest::SHA512.hexdigest("changeme")
64
+ ENV['ADMIN_PASSWORD_HASH'] = 'f1891cea80fc05e433c943254c6bdabc159577a02a7395dfebbfbc4f7661d4af56f2d372131a45936de40160007368a56ef216a30cb202c66d3145fd24380906'
65
+ config = Rails.root.join('config/tiny_admin.yml').to_s
66
+ TinyAdmin.configure_from_file(config)
67
+ ```
68
+
69
+ ```yml
70
+ ---
71
+ authentication:
72
+ plugin: TinyAdmin::Plugins::SimpleAuth
73
+ page_not_found: Admin::PageNotFound
74
+ record_not_found: Admin::RecordNotFound
75
+ root:
76
+ title: 'Tiny Admin'
77
+ page: Admin::PageRoot
78
+ # redirect: posts
79
+ sections:
80
+ - slug: google
81
+ name: Google.it
82
+ type: url
83
+ url: https://www.google.it
84
+ options:
85
+ target: '_blank'
86
+ - slug: stats
87
+ name: Stats
88
+ type: page
89
+ page: Admin::Stats
90
+ - slug: authors
91
+ name: Authors
92
+ type: resource
93
+ model: Author
94
+ repository: Admin::AuthorsRepo
95
+ collection_actions:
96
+ - latests: Admin::LatestAuthorsAction
97
+ member_actions:
98
+ - csv_export: Admin::CsvExportAuthorAction
99
+ # only:
100
+ # - index
101
+ # options:
102
+ # - hidden
103
+ - slug: posts
104
+ name: Posts
105
+ type: resource
106
+ model: Post
107
+ index:
108
+ sort:
109
+ - author_id DESC
110
+ pagination: 15
111
+ attributes:
112
+ - id
113
+ - title
114
+ - field: author_id
115
+ link_to: authors
116
+ - state
117
+ - published
118
+ - dt
119
+ - field: created_at
120
+ converter: Admin::Utils
121
+ method: datetime_formatter
122
+ filters:
123
+ - title
124
+ - field: state
125
+ type: select
126
+ values:
127
+ - available
128
+ - unavailable
129
+ - arriving
130
+ - published
131
+ show:
132
+ attributes:
133
+ - id
134
+ - title
135
+ - description
136
+ - field: author_id
137
+ link_to: authors
138
+ - category
139
+ - published
140
+ - state
141
+ - created_at
142
+ style_links:
143
+ - href: /bootstrap.min.css
144
+ rel: stylesheet
145
+ scripts:
146
+ - src: /bootstrap.bundle.min.js
147
+ extra_styles: >
148
+ .navbar {
149
+ background-color: var(--bs-cyan);
150
+ }
151
+ .main-content {
152
+ background-color: var(--bs-gray-100);
153
+ }
154
+ .main-content a {
155
+ text-decoration: none;
156
+ }
157
+ ```
158
+
159
+ ## Do you like it? Star it!
160
+
161
+ If you use this component just star it. A developer is more motivated to improve a project when there is some interest.
162
+
163
+ Or consider offering me a coffee, it's a small thing but it is greatly appreciated: [about me](https://www.blocknot.es/about-me).
164
+
165
+ ## Contributors
166
+
167
+ - [Mattia Roccoberton](https://blocknot.es/): author
168
+
169
+ ## License
170
+
171
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Actions
5
+ class BasicAction
6
+ include Utils
7
+
8
+ attr_reader :params, :path, :repository
9
+
10
+ def initialize(repository, path:, params:)
11
+ @repository = repository
12
+ @path = path
13
+ @params = params
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Actions
5
+ class Index < BasicAction
6
+ attr_reader :current_page, :fields_options, :filters_list, :pagination, :query_string, :sort
7
+
8
+ def call(app:, context:, options:, actions:)
9
+ evaluate_options(options)
10
+ fields = repository.fields(options: fields_options)
11
+ filters = prepare_filters(fields, filters_list)
12
+ records, total_count = repository.list(page: current_page, limit: pagination, filters: filters, sort: sort)
13
+ prepare_record = ->(record) { repository.index_record_attrs(record, fields: fields_options) }
14
+ title = repository.index_title
15
+ pages = (total_count / pagination) + 1
16
+
17
+ prepare_page(Views::Actions::Index, title: title, context: context, query_string: query_string) do |page|
18
+ page.setup_pagination(current_page: current_page, pages: pages > 1 ? pages : false)
19
+ page.setup_records(records: records, fields: fields, prepare_record: prepare_record)
20
+ page.actions = actions
21
+ page.filters = filters
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def evaluate_options(options)
28
+ @fields_options = options[:attributes]&.each_with_object({}) do |field, result|
29
+ result.merge!(field.is_a?(Hash) ? { field[:field] => field } : { field => { field: field } })
30
+ end
31
+ @filters_list = options[:filters]
32
+ @pagination = options[:pagination] || 10
33
+ @sort = options[:sort] || ['id']
34
+
35
+ @current_page = (params['p'] || 1).to_i
36
+ @query_string = params_to_s(params.reject { |k, _v| k == 'p' })
37
+ end
38
+
39
+ def prepare_filters(fields, filters_list)
40
+ filters = (filters_list || []).map { _1.is_a?(Hash) ? _1 : { field: _1 } }.index_by { _1[:field] }
41
+ values = (params['q'] || {})
42
+ fields.each_with_object({}) do |field, result|
43
+ result[field] = { value: values[field.name], filter: filters[field.name] } if filters.key?(field.name)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Actions
5
+ class Show < BasicAction
6
+ def call(app:, context:, options:, actions:)
7
+ fields_options = (options[:attributes] || []).each_with_object({}) do |field, result|
8
+ result.merge!(field.is_a?(Hash) ? { field[:field] => field } : { field => { field: field } })
9
+ end
10
+ record = repository.find(context.reference)
11
+ prepare_record = ->(record_data) { repository.show_record_attrs(record_data, fields: fields_options) }
12
+ fields = repository.fields(options: fields_options)
13
+
14
+ prepare_page(Views::Actions::Show, title: repository.show_title(record), context: context) do |page|
15
+ page.setup_record(record: record, fields: fields, prepare_record: prepare_record)
16
+ page.actions = actions
17
+ end
18
+ rescue Plugins::BaseRepository::RecordNotFound => _e
19
+ prepare_page(options[:record_not_found_page] || Views::Pages::RecordNotFound)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ class Authentication < BasicApp
5
+ route do |r|
6
+ r.get 'unauthenticated' do
7
+ if current_user
8
+ r.redirect settings.root_path
9
+ else
10
+ render_login
11
+ end
12
+ end
13
+
14
+ r.post 'unauthenticated' do
15
+ render_login(warnings: ['Failed to authenticate'])
16
+ end
17
+
18
+ r.get 'logout' do
19
+ logout_user
20
+ r.redirect settings.root_path
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def render_login(notices: nil, warnings: nil, errors: nil)
27
+ page = prepare_page(settings.authentication[:login], options: %i[no_menu compact_layout])
28
+ page.setup_flash_messages(
29
+ notices: notices || flash['notices'],
30
+ warnings: warnings || flash['warnings'],
31
+ errors: errors || flash['errors']
32
+ )
33
+ render(inline: page.call)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ class BasicApp < Roda
5
+ include Utils
6
+
7
+ class << self
8
+ def authentication_plugin
9
+ plugin = TinyAdmin::Settings.instance.authentication&.dig(:plugin)
10
+ plugin_class = plugin.is_a?(String) ? Object.const_get(plugin) : plugin
11
+ plugin_class || TinyAdmin::Plugins::NoAuth
12
+ end
13
+ end
14
+
15
+ plugin :flash
16
+ plugin :not_found
17
+ plugin :render, engine: 'html'
18
+ plugin :sessions, secret: SecureRandom.hex(64)
19
+
20
+ plugin authentication_plugin
21
+
22
+ not_found { prepare_page(settings.page_not_found).call }
23
+
24
+ def attach_flash_messages(page)
25
+ return unless page.respond_to?(:setup_flash_messages)
26
+
27
+ page.setup_flash_messages(notices: flash['notices'], warnings: flash['warnings'], errors: flash['errors'])
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ class Context
5
+ include Singleton
6
+
7
+ attr_accessor :reference, :slug
8
+ end
9
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ class Field
5
+ attr_reader :name, :options, :title, :type
6
+
7
+ def initialize(type:, name:, title:, options: {})
8
+ @type = type
9
+ @name = name
10
+ @title = title || name
11
+ @options = options
12
+ end
13
+
14
+ class << self
15
+ def create_field(name:, title:, type: nil, options: {})
16
+ new(type: type, name: name, title: title, options: options)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Plugins
5
+ class ActiveRecordRepository < BaseRepository
6
+ def index_record_attrs(record, fields: nil)
7
+ return record.attributes.transform_values(&:to_s) if !fields || fields.empty?
8
+
9
+ record.attributes.slice(*fields.keys).each_with_object({}) do |(key, value), result|
10
+ field_data = fields[key]
11
+ result[key] =
12
+ if field_data[:converter] && field_data[:method]
13
+ converter = Object.const_get(field_data[:converter])
14
+ converter.send(field_data[:method], value)
15
+ else
16
+ value&.to_s
17
+ end
18
+ end
19
+ end
20
+
21
+ def index_title
22
+ title = model.to_s
23
+ title.respond_to?(:pluralize) ? title.pluralize : title
24
+ end
25
+
26
+ def fields(options: nil)
27
+ opts = options || {}
28
+ columns = model.columns
29
+ if !opts.empty?
30
+ extra_fields = opts.keys - model.column_names
31
+ raise "Some requested fields are not available: #{extra_fields.join(', ')}" if extra_fields.any?
32
+
33
+ columns = opts.keys.map { |field| columns.find { _1.name == field } }
34
+ end
35
+ columns.map do |column|
36
+ name = column.name
37
+ type = opts.dig(column.name, :type) || column.type
38
+ TinyAdmin::Field.create_field(name: name, title: name.humanize, type: type, options: opts[name])
39
+ end
40
+ end
41
+
42
+ def show_record_attrs(record, fields: nil)
43
+ attrs = !fields || fields.empty? ? record.attributes : record.attributes.slice(*fields.keys)
44
+ attrs.transform_values(&:to_s)
45
+ end
46
+
47
+ def show_title(record)
48
+ "#{model} ##{record.id}"
49
+ end
50
+
51
+ def find(reference)
52
+ model.find(reference)
53
+ rescue ActiveRecord::RecordNotFound => e
54
+ raise BaseRepository::RecordNotFound, e.message
55
+ end
56
+
57
+ def list(page: 1, limit: 10, filters: nil, sort: ['id'])
58
+ page_offset = page.positive? ? (page - 1) * limit : 0
59
+ query = model.all.order(sort)
60
+ query = apply_filters(query, filters) if filters
61
+ records = query.offset(page_offset).limit(limit).to_a
62
+ [records, query.count]
63
+ end
64
+
65
+ def apply_filters(query, filters)
66
+ filters.each do |field, filter|
67
+ value = filter&.dig(:value)
68
+ next if value.nil? || value == ''
69
+
70
+ query =
71
+ case field.type
72
+ when :string
73
+ value = ActiveRecord::Base.sanitize_sql_like(value.strip)
74
+ query.where("#{field.name} LIKE ?", "%#{value}%")
75
+ else
76
+ query.where(field.name => value)
77
+ end
78
+ end
79
+ query
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Plugins
5
+ class BaseRepository
6
+ RecordNotFound = Class.new(StandardError)
7
+
8
+ attr_reader :model
9
+
10
+ def initialize(model)
11
+ @model = model
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Plugins
5
+ module NoAuth
6
+ class << self
7
+ def configure(_app, _opts = {}); end
8
+ end
9
+
10
+ module InstanceMethods
11
+ def authenticate_user!
12
+ true
13
+ end
14
+
15
+ def current_user
16
+ 'admin'
17
+ end
18
+
19
+ def logout_user
20
+ nil
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module TinyAdmin
6
+ module Plugins
7
+ module SimpleAuth
8
+ class << self
9
+ def configure(app, opts = {})
10
+ Warden::Strategies.add(:secret) do
11
+ def authenticate!
12
+ hash = ENV.fetch('ADMIN_PASSWORD_HASH')
13
+ secret = params['secret']
14
+ return fail(:invalid_credentials) if !secret || Digest::SHA512.hexdigest(secret) != hash
15
+
16
+ success!(app: 'TinyAdmin')
17
+ end
18
+ end
19
+
20
+ app.opts[:login_form] = opts[:login_form] || TinyAdmin::Views::Pages::SimpleAuthLogin
21
+
22
+ app.use Warden::Manager do |manager|
23
+ manager.default_strategies :secret
24
+ manager.failure_app = TinyAdmin::Authentication
25
+ end
26
+ end
27
+ end
28
+
29
+ module InstanceMethods
30
+ def authenticate_user!
31
+ env['warden'].authenticate!
32
+ end
33
+
34
+ def current_user
35
+ env['warden'].user
36
+ end
37
+
38
+ def logout_user
39
+ env['warden'].logout
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ class Router < BasicApp
5
+ TinyAdmin::Settings.instance.load_settings
6
+
7
+ route do |r|
8
+ r.on 'auth' do
9
+ context.slug = nil
10
+ r.run Authentication
11
+ end
12
+
13
+ authenticate_user!
14
+
15
+ r.root do
16
+ root_route(r)
17
+ end
18
+
19
+ r.post '' do
20
+ context.slug = nil
21
+ r.redirect settings.root_path
22
+ end
23
+
24
+ TinyAdmin::Settings.instance.pages.each do |slug, data|
25
+ setup_page_route(r, slug, data)
26
+ end
27
+
28
+ TinyAdmin::Settings.instance.resources.each do |slug, options|
29
+ setup_resource_routes(r, slug, options: options || {})
30
+ end
31
+
32
+ nil # NOTE: needed to skip the last line (each) return value
33
+ end
34
+
35
+ private
36
+
37
+ def render_page(page)
38
+ attach_flash_messages(page)
39
+ render(inline: page.call)
40
+ end
41
+
42
+ def root_route(router)
43
+ context.slug = nil
44
+ if settings.root[:redirect]
45
+ router.redirect "#{settings.root_path}/#{settings.root[:redirect]}"
46
+ else
47
+ page = settings.root[:page]
48
+ page_class = page.is_a?(String) ? Object.const_get(page) : page
49
+ render_page prepare_page(page_class, context: context)
50
+ end
51
+ end
52
+
53
+ def setup_page_route(router, slug, page_class)
54
+ router.get slug do
55
+ context.slug = slug
56
+ render_page prepare_page(page_class, context: context)
57
+ end
58
+ end
59
+
60
+ def setup_resource_routes(router, slug, options:)
61
+ router.on slug do
62
+ context.slug = slug
63
+ setup_collection_routes(router, slug: slug, options: options)
64
+ setup_member_routes(router, slug: slug, options: options)
65
+ end
66
+ end
67
+
68
+ def setup_collection_routes(router, slug:, options:)
69
+ repository = options[:repository].new(options[:model])
70
+ index_options = options[:index] || {}
71
+ custom_actions = []
72
+
73
+ # Custom actions
74
+ (options[:collection_actions] || []).each do |custom_action|
75
+ action_slug, action = custom_action.first
76
+ action_class = action.is_a?(String) ? Object.const_get(action) : action
77
+ custom_actions << action_slug.to_s
78
+ router.get action_slug.to_s do
79
+ custom_action = action_class.new(repository, path: request.path, params: request.params)
80
+ render_page custom_action.call(app: self, context: context, options: index_options)
81
+ end
82
+ end
83
+
84
+ # Index
85
+ actions = options[:only]
86
+ if !actions || actions.include?(:index) || actions.include?('index')
87
+ router.is do
88
+ index_action = TinyAdmin::Actions::Index.new(repository, path: request.path, params: request.params)
89
+ render_page index_action.call(app: self, context: context, options: index_options, actions: custom_actions)
90
+ end
91
+ end
92
+ end
93
+
94
+ def setup_member_routes(router, slug:, options:)
95
+ repository = options[:repository].new(options[:model])
96
+ show_options = (options[:show] || {}).merge(record_not_found_page: settings.record_not_found)
97
+ custom_actions = []
98
+
99
+ router.on String do |reference|
100
+ context.reference = reference
101
+
102
+ # Custom actions
103
+ (options[:member_actions] || []).each do |custom_action|
104
+ action_slug, action = custom_action.first
105
+ action_class = action.is_a?(String) ? Object.const_get(action) : action
106
+ custom_actions << action_slug.to_s
107
+
108
+ router.get action_slug.to_s do
109
+ custom_action = action_class.new(repository, path: request.path, params: request.params)
110
+ render_page custom_action.call(app: self, context: context, options: show_options)
111
+ end
112
+ end
113
+
114
+ # Show
115
+ actions = options[:only]
116
+ if !actions || actions.include?(:show) || actions.include?('show')
117
+ router.is do
118
+ show_action = TinyAdmin::Actions::Show.new(repository, path: request.path, params: request.params)
119
+ render_page show_action.call(app: self, context: context, options: show_options, actions: custom_actions)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end