tiny_admin 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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