switchlet 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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +16 -0
- data/app/controllers/switchlet/application_controller.rb +58 -0
- data/app/controllers/switchlet/flags_controller.rb +38 -0
- data/app/views/switchlet/flags/index.html.erb +207 -0
- data/config/routes.rb +11 -0
- data/lib/generators/switchlet/install_generator.rb +26 -0
- data/lib/generators/switchlet/templates/create_switchlet_flags.rb +12 -0
- data/lib/generators/switchlet/templates/switchlet.rb +49 -0
- data/lib/switchlet/configuration.rb +45 -0
- data/lib/switchlet/engine.rb +13 -0
- data/lib/switchlet/flag.rb +9 -0
- data/lib/switchlet/railtie.rb +9 -0
- data/lib/switchlet/version.rb +5 -0
- data/lib/switchlet.rb +50 -0
- data/lib/tasks/switchlet.rake +43 -0
- data/sig/switchlet.rbs +4 -0
- metadata +103 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b6901df6416c4272e12d2ed50373d891dc478b2241ccdca64f11e7896165eed0
|
|
4
|
+
data.tar.gz: 56ef7422263e1de906d034888bbabe498bb3cffe7eef1278cf88d8051a3c0d60
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9650d4b3ec6a1d6fa6f5be4e54e4a6eb1a64d56accc8e4a293da458262d3a88ffcc6d760d5794549c68952516753cac336714afdbbc46f2b00e0ac83a70b6394
|
|
7
|
+
data.tar.gz: cbcc1830f2c693c46b2b9fd7675a1bffb257af4e9ef134b2e50d554992f3a0fa210f2d8c72b34a1f6d10e10a4ee655d765523622afdc38af05a87292de88fd57
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 komagata
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Switchlet
|
|
2
|
+
|
|
3
|
+
Minimal feature flag gem for Rails 6.1+. Simple boolean feature flags stored in database.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'switchlet'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then execute:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
rails generate switchlet:install
|
|
18
|
+
rails db:migrate
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# Check if feature is enabled (returns false for unregistered flags)
|
|
25
|
+
Switchlet.enabled?(:my_feature) # => false
|
|
26
|
+
|
|
27
|
+
# Enable a feature
|
|
28
|
+
Switchlet.enable!(:my_feature) # => true
|
|
29
|
+
|
|
30
|
+
# Disable a feature
|
|
31
|
+
Switchlet.disable!(:my_feature) # => false
|
|
32
|
+
|
|
33
|
+
# Delete a feature flag
|
|
34
|
+
Switchlet.delete!(:my_feature) # => nil
|
|
35
|
+
|
|
36
|
+
# List all feature flags
|
|
37
|
+
Switchlet.list # => [{ name: "my_feature", enabled: true, updated_at: Time }]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Web UI
|
|
41
|
+
|
|
42
|
+
Switchlet includes a web interface for managing feature flags:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# Add to your routes.rb
|
|
46
|
+
mount Switchlet::Engine => "/switchlet"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then visit `/switchlet` in your browser to:
|
|
50
|
+
- View all feature flags
|
|
51
|
+
- Toggle flags ON/OFF
|
|
52
|
+
- Create new flags
|
|
53
|
+
- Delete existing flags
|
|
54
|
+
|
|
55
|
+
### Securing the Web Interface
|
|
56
|
+
|
|
57
|
+
**⚠️ Important: The web interface should be secured in production environments.**
|
|
58
|
+
|
|
59
|
+
#### Custom Authentication (Recommended)
|
|
60
|
+
|
|
61
|
+
Integrate with your existing authentication system:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# config/initializers/switchlet.rb
|
|
65
|
+
Switchlet.configure do |config|
|
|
66
|
+
# Devise + admin role
|
|
67
|
+
config.authenticate_with do |controller|
|
|
68
|
+
controller.authenticate_user! && controller.current_user.admin?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Examples for different authentication systems:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# CanCanCan authorization
|
|
77
|
+
config.authenticate_with do |controller|
|
|
78
|
+
controller.authorize! :manage, :switchlet
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Custom session-based authentication
|
|
82
|
+
config.authenticate_with do |controller|
|
|
83
|
+
controller.session[:admin_logged_in] == true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Simple role check
|
|
87
|
+
config.authenticate_with do |controller|
|
|
88
|
+
controller.current_user&.role == 'admin'
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### Basic Authentication
|
|
93
|
+
|
|
94
|
+
For simple username/password protection:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# config/initializers/switchlet.rb
|
|
98
|
+
Switchlet.configure do |config|
|
|
99
|
+
config.basic_auth_enabled = Rails.env.production?
|
|
100
|
+
config.basic_auth_username = ENV['SWITCHLET_USERNAME']
|
|
101
|
+
config.basic_auth_password = ENV['SWITCHLET_PASSWORD']
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
#### IP Restriction
|
|
106
|
+
|
|
107
|
+
Restrict access to specific IP addresses:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
# config/initializers/switchlet.rb
|
|
111
|
+
Switchlet.configure do |config|
|
|
112
|
+
config.allowed_ips = [
|
|
113
|
+
'127.0.0.1', # localhost
|
|
114
|
+
'10.0.0.0/8', # private network
|
|
115
|
+
'192.168.1.0/24' # local network
|
|
116
|
+
]
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### Multiple Authentication Methods
|
|
121
|
+
|
|
122
|
+
You can combine multiple methods. The authentication priority is:
|
|
123
|
+
1. Custom authentication block (highest priority)
|
|
124
|
+
2. IP restriction (if no custom auth)
|
|
125
|
+
3. Basic authentication (if configured)
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
Switchlet.configure do |config|
|
|
129
|
+
# Custom auth takes precedence
|
|
130
|
+
config.authenticate_with do |controller|
|
|
131
|
+
controller.current_user&.admin?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# IP restriction works alongside custom auth
|
|
135
|
+
config.allowed_ips = ['10.0.0.0/8']
|
|
136
|
+
|
|
137
|
+
# Basic auth as fallback (won't be used if custom auth is set)
|
|
138
|
+
config.basic_auth_enabled = true
|
|
139
|
+
config.basic_auth_username = ENV['SWITCHLET_USERNAME']
|
|
140
|
+
config.basic_auth_password = ENV['SWITCHLET_PASSWORD']
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Rake Tasks
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
rake switchlet:list
|
|
148
|
+
rake switchlet:enable[feature_name]
|
|
149
|
+
rake switchlet:disable[feature_name]
|
|
150
|
+
rake switchlet:delete[feature_name]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Notes
|
|
154
|
+
|
|
155
|
+
- No caching - reads directly from database
|
|
156
|
+
- Unregistered flags return `false`
|
|
157
|
+
- No YAML/ENV/actor/percentage support
|
|
158
|
+
- Rails 6.1+ required
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
163
|
+
|
|
164
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
165
|
+
|
|
166
|
+
## Contributing
|
|
167
|
+
|
|
168
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/komagata/switchlet.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << "test"
|
|
8
|
+
t.libs << "lib"
|
|
9
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require "rubocop/rake_task"
|
|
13
|
+
|
|
14
|
+
RuboCop::RakeTask.new
|
|
15
|
+
|
|
16
|
+
task default: %i[test rubocop]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Switchlet
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
before_action :authenticate_switchlet_access!
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def authenticate_switchlet_access!
|
|
11
|
+
config = Switchlet.configuration
|
|
12
|
+
|
|
13
|
+
# 1. Check custom authentication block (highest priority)
|
|
14
|
+
if config.authenticate_block?
|
|
15
|
+
return if instance_exec(self, &config.authenticate_block)
|
|
16
|
+
render_access_denied
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# 2. Check IP restriction
|
|
21
|
+
unless config.ip_allowed?(request.remote_ip)
|
|
22
|
+
render_access_denied("IP address not allowed")
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 3. Check Basic authentication
|
|
27
|
+
if config.basic_auth_configured?
|
|
28
|
+
authenticate_or_request_with_http_basic("Switchlet Admin") do |username, password|
|
|
29
|
+
username == config.basic_auth_username && password == config.basic_auth_password
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render_access_denied(message = "Access denied")
|
|
35
|
+
if request.format.html?
|
|
36
|
+
render html: <<~HTML.html_safe, status: :forbidden
|
|
37
|
+
<!DOCTYPE html>
|
|
38
|
+
<html>
|
|
39
|
+
<head>
|
|
40
|
+
<title>Access Denied</title>
|
|
41
|
+
<style>
|
|
42
|
+
body { font-family: Arial, sans-serif; text-align: center; margin-top: 100px; }
|
|
43
|
+
.error { color: #e74c3c; }
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<h1 class="error">🚫 Access Denied</h1>
|
|
48
|
+
<p>#{message}</p>
|
|
49
|
+
<p>You don't have permission to access Switchlet admin interface.</p>
|
|
50
|
+
</body>
|
|
51
|
+
</html>
|
|
52
|
+
HTML
|
|
53
|
+
else
|
|
54
|
+
render json: { error: message }, status: :forbidden
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Switchlet
|
|
4
|
+
class FlagsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@flags = Switchlet.list
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def toggle
|
|
10
|
+
flag_name = params[:name]
|
|
11
|
+
current_state = Switchlet.enabled?(flag_name)
|
|
12
|
+
|
|
13
|
+
if current_state
|
|
14
|
+
Switchlet.disable!(flag_name)
|
|
15
|
+
else
|
|
16
|
+
Switchlet.enable!(flag_name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
redirect_to switchlet.flags_path, notice: "Flag '#{flag_name}' #{current_state ? 'disabled' : 'enabled'}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create
|
|
23
|
+
flag_name = params[:flag_name].strip
|
|
24
|
+
if flag_name.present?
|
|
25
|
+
Switchlet.enable!(flag_name)
|
|
26
|
+
redirect_to switchlet.flags_path, notice: "Flag '#{flag_name}' created and enabled"
|
|
27
|
+
else
|
|
28
|
+
redirect_to switchlet.flags_path, alert: "Flag name cannot be empty"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def destroy
|
|
33
|
+
flag_name = params[:name]
|
|
34
|
+
Switchlet.delete!(flag_name)
|
|
35
|
+
redirect_to switchlet.flags_path, notice: "Flag '#{flag_name}' deleted"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Switchlet - Feature Flags</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 20px;
|
|
12
|
+
background-color: #f8f9fa;
|
|
13
|
+
color: #333;
|
|
14
|
+
}
|
|
15
|
+
.container {
|
|
16
|
+
max-width: 800px;
|
|
17
|
+
margin: 0 auto;
|
|
18
|
+
background: white;
|
|
19
|
+
padding: 30px;
|
|
20
|
+
border-radius: 8px;
|
|
21
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
22
|
+
}
|
|
23
|
+
h1 {
|
|
24
|
+
color: #2c3e50;
|
|
25
|
+
margin-bottom: 30px;
|
|
26
|
+
border-bottom: 2px solid #3498db;
|
|
27
|
+
padding-bottom: 10px;
|
|
28
|
+
}
|
|
29
|
+
.alert {
|
|
30
|
+
padding: 12px 16px;
|
|
31
|
+
border-radius: 4px;
|
|
32
|
+
margin-bottom: 20px;
|
|
33
|
+
}
|
|
34
|
+
.alert-success {
|
|
35
|
+
background-color: #d4edda;
|
|
36
|
+
color: #155724;
|
|
37
|
+
border: 1px solid #c3e6cb;
|
|
38
|
+
}
|
|
39
|
+
.alert-danger {
|
|
40
|
+
background-color: #f8d7da;
|
|
41
|
+
color: #721c24;
|
|
42
|
+
border: 1px solid #f5c6cb;
|
|
43
|
+
}
|
|
44
|
+
.create-form {
|
|
45
|
+
background: #f8f9fa;
|
|
46
|
+
padding: 20px;
|
|
47
|
+
border-radius: 4px;
|
|
48
|
+
margin-bottom: 30px;
|
|
49
|
+
}
|
|
50
|
+
.create-form h2 {
|
|
51
|
+
margin-top: 0;
|
|
52
|
+
color: #2c3e50;
|
|
53
|
+
}
|
|
54
|
+
.form-group {
|
|
55
|
+
display: flex;
|
|
56
|
+
gap: 10px;
|
|
57
|
+
align-items: center;
|
|
58
|
+
}
|
|
59
|
+
input[type="text"] {
|
|
60
|
+
flex: 1;
|
|
61
|
+
padding: 8px 12px;
|
|
62
|
+
border: 1px solid #ddd;
|
|
63
|
+
border-radius: 4px;
|
|
64
|
+
font-size: 14px;
|
|
65
|
+
}
|
|
66
|
+
.btn {
|
|
67
|
+
padding: 8px 16px;
|
|
68
|
+
border: none;
|
|
69
|
+
border-radius: 4px;
|
|
70
|
+
cursor: pointer;
|
|
71
|
+
text-decoration: none;
|
|
72
|
+
font-size: 14px;
|
|
73
|
+
display: inline-block;
|
|
74
|
+
}
|
|
75
|
+
.btn-primary {
|
|
76
|
+
background-color: #3498db;
|
|
77
|
+
color: white;
|
|
78
|
+
}
|
|
79
|
+
.btn-primary:hover {
|
|
80
|
+
background-color: #2980b9;
|
|
81
|
+
}
|
|
82
|
+
.btn-success {
|
|
83
|
+
background-color: #27ae60;
|
|
84
|
+
color: white;
|
|
85
|
+
}
|
|
86
|
+
.btn-success:hover {
|
|
87
|
+
background-color: #219a52;
|
|
88
|
+
}
|
|
89
|
+
.btn-danger {
|
|
90
|
+
background-color: #e74c3c;
|
|
91
|
+
color: white;
|
|
92
|
+
}
|
|
93
|
+
.btn-danger:hover {
|
|
94
|
+
background-color: #c0392b;
|
|
95
|
+
}
|
|
96
|
+
.btn-secondary {
|
|
97
|
+
background-color: #95a5a6;
|
|
98
|
+
color: white;
|
|
99
|
+
}
|
|
100
|
+
.btn-secondary:hover {
|
|
101
|
+
background-color: #7f8c8d;
|
|
102
|
+
}
|
|
103
|
+
.flags-table {
|
|
104
|
+
width: 100%;
|
|
105
|
+
border-collapse: collapse;
|
|
106
|
+
margin-top: 20px;
|
|
107
|
+
}
|
|
108
|
+
.flags-table th,
|
|
109
|
+
.flags-table td {
|
|
110
|
+
padding: 12px;
|
|
111
|
+
text-align: left;
|
|
112
|
+
border-bottom: 1px solid #ddd;
|
|
113
|
+
}
|
|
114
|
+
.flags-table th {
|
|
115
|
+
background-color: #f8f9fa;
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
color: #2c3e50;
|
|
118
|
+
}
|
|
119
|
+
.status-enabled {
|
|
120
|
+
color: #27ae60;
|
|
121
|
+
font-weight: bold;
|
|
122
|
+
}
|
|
123
|
+
.status-disabled {
|
|
124
|
+
color: #e74c3c;
|
|
125
|
+
font-weight: bold;
|
|
126
|
+
}
|
|
127
|
+
.actions {
|
|
128
|
+
display: flex;
|
|
129
|
+
gap: 8px;
|
|
130
|
+
}
|
|
131
|
+
.empty-state {
|
|
132
|
+
text-align: center;
|
|
133
|
+
padding: 40px;
|
|
134
|
+
color: #7f8c8d;
|
|
135
|
+
}
|
|
136
|
+
</style>
|
|
137
|
+
</head>
|
|
138
|
+
<body>
|
|
139
|
+
<div class="container">
|
|
140
|
+
<h1>🎛️ Switchlet - Feature Flags</h1>
|
|
141
|
+
|
|
142
|
+
<% if notice %>
|
|
143
|
+
<div class="alert alert-success"><%= notice %></div>
|
|
144
|
+
<% end %>
|
|
145
|
+
|
|
146
|
+
<% if alert %>
|
|
147
|
+
<div class="alert alert-danger"><%= alert %></div>
|
|
148
|
+
<% end %>
|
|
149
|
+
|
|
150
|
+
<div class="create-form">
|
|
151
|
+
<h2>Create New Flag</h2>
|
|
152
|
+
<%= form_with url: switchlet.flags_path, local: true do |form| %>
|
|
153
|
+
<div class="form-group">
|
|
154
|
+
<%= form.text_field :flag_name, placeholder: "Enter flag name...", required: true %>
|
|
155
|
+
<%= form.submit "Create Flag", class: "btn btn-primary" %>
|
|
156
|
+
</div>
|
|
157
|
+
<% end %>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<% if @flags.empty? %>
|
|
161
|
+
<div class="empty-state">
|
|
162
|
+
<h3>No feature flags found</h3>
|
|
163
|
+
<p>Create your first feature flag using the form above.</p>
|
|
164
|
+
</div>
|
|
165
|
+
<% else %>
|
|
166
|
+
<table class="flags-table">
|
|
167
|
+
<thead>
|
|
168
|
+
<tr>
|
|
169
|
+
<th>Flag Name</th>
|
|
170
|
+
<th>Status</th>
|
|
171
|
+
<th>Last Updated</th>
|
|
172
|
+
<th>Actions</th>
|
|
173
|
+
</tr>
|
|
174
|
+
</thead>
|
|
175
|
+
<tbody>
|
|
176
|
+
<% @flags.each do |flag| %>
|
|
177
|
+
<tr>
|
|
178
|
+
<td><strong><%= flag[:name] %></strong></td>
|
|
179
|
+
<td>
|
|
180
|
+
<span class="<%= flag[:enabled] ? 'status-enabled' : 'status-disabled' %>">
|
|
181
|
+
<%= flag[:enabled] ? 'ENABLED' : 'DISABLED' %>
|
|
182
|
+
</span>
|
|
183
|
+
</td>
|
|
184
|
+
<td><%= flag[:updated_at].strftime("%Y-%m-%d %H:%M") %></td>
|
|
185
|
+
<td>
|
|
186
|
+
<div class="actions">
|
|
187
|
+
<%= link_to switchlet.toggle_flag_path(flag[:name]),
|
|
188
|
+
method: :patch,
|
|
189
|
+
class: "btn #{flag[:enabled] ? 'btn-secondary' : 'btn-success'}" do %>
|
|
190
|
+
<%= flag[:enabled] ? 'Disable' : 'Enable' %>
|
|
191
|
+
<% end %>
|
|
192
|
+
<%= link_to switchlet.flag_path(flag[:name]),
|
|
193
|
+
method: :delete,
|
|
194
|
+
class: "btn btn-danger",
|
|
195
|
+
confirm: "Are you sure you want to delete '#{flag[:name]}'?" do %>
|
|
196
|
+
Delete
|
|
197
|
+
<% end %>
|
|
198
|
+
</div>
|
|
199
|
+
</td>
|
|
200
|
+
</tr>
|
|
201
|
+
<% end %>
|
|
202
|
+
</tbody>
|
|
203
|
+
</table>
|
|
204
|
+
<% end %>
|
|
205
|
+
</div>
|
|
206
|
+
</body>
|
|
207
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
|
|
6
|
+
module Switchlet
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
def self.next_migration_number(path)
|
|
14
|
+
ActiveRecord::Generators::Base.next_migration_number(path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
migration_template "create_switchlet_flags.rb", "db/migrate/create_switchlet_flags.rb"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create_initializer_file
|
|
22
|
+
copy_file "switchlet.rb", "config/initializers/switchlet.rb"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateSwitchletFlags < ActiveRecord::Migration[6.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :switchlet_flags do |t|
|
|
6
|
+
t.string :name, null: false
|
|
7
|
+
t.boolean :enabled, null: false, default: false
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
add_index :switchlet_flags, :name, unique: true
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Switchlet configuration
|
|
4
|
+
Switchlet.configure do |config|
|
|
5
|
+
# === Custom Authentication (Recommended) ===
|
|
6
|
+
# Integrate with your existing authentication system
|
|
7
|
+
# This takes highest priority over other authentication methods
|
|
8
|
+
|
|
9
|
+
# Example 1: Devise + admin role
|
|
10
|
+
# config.authenticate_with do |controller|
|
|
11
|
+
# controller.authenticate_user! && controller.current_user.admin?
|
|
12
|
+
# end
|
|
13
|
+
|
|
14
|
+
# Example 2: CanCanCan authorization
|
|
15
|
+
# config.authenticate_with do |controller|
|
|
16
|
+
# controller.authorize! :manage, :switchlet
|
|
17
|
+
# end
|
|
18
|
+
|
|
19
|
+
# Example 3: Custom session-based authentication
|
|
20
|
+
# config.authenticate_with do |controller|
|
|
21
|
+
# controller.session[:admin_logged_in] == true
|
|
22
|
+
# end
|
|
23
|
+
|
|
24
|
+
# === Basic Authentication ===
|
|
25
|
+
# Simple username/password authentication using environment variables
|
|
26
|
+
# Only used if no custom authentication block is set
|
|
27
|
+
|
|
28
|
+
# config.basic_auth_enabled = Rails.env.production?
|
|
29
|
+
# config.basic_auth_username = ENV['SWITCHLET_USERNAME']
|
|
30
|
+
# config.basic_auth_password = ENV['SWITCHLET_PASSWORD']
|
|
31
|
+
|
|
32
|
+
# === IP Restriction ===
|
|
33
|
+
# Allow access only from specific IP addresses
|
|
34
|
+
# Supports both single IPs and CIDR notation
|
|
35
|
+
# Works in combination with other authentication methods
|
|
36
|
+
|
|
37
|
+
# config.allowed_ips = [
|
|
38
|
+
# '127.0.0.1', # localhost
|
|
39
|
+
# '10.0.0.0/8', # private network
|
|
40
|
+
# '192.168.1.0/24' # local network
|
|
41
|
+
# ]
|
|
42
|
+
|
|
43
|
+
# === Development Settings ===
|
|
44
|
+
# In development, you might want to disable authentication entirely
|
|
45
|
+
unless Rails.env.development?
|
|
46
|
+
# Enable authentication in staging/production
|
|
47
|
+
# Choose one of the methods above
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module Switchlet
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor :basic_auth_enabled, :basic_auth_username, :basic_auth_password
|
|
8
|
+
attr_accessor :allowed_ips
|
|
9
|
+
attr_reader :authenticate_block
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@basic_auth_enabled = false
|
|
13
|
+
@basic_auth_username = nil
|
|
14
|
+
@basic_auth_password = nil
|
|
15
|
+
@allowed_ips = []
|
|
16
|
+
@authenticate_block = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def authenticate_with(&block)
|
|
20
|
+
@authenticate_block = block
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def authenticate_block?
|
|
24
|
+
!@authenticate_block.nil?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def basic_auth_configured?
|
|
28
|
+
basic_auth_enabled && basic_auth_username && basic_auth_password
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ip_allowed?(ip_address)
|
|
32
|
+
return true if allowed_ips.empty?
|
|
33
|
+
|
|
34
|
+
allowed_ips.any? do |allowed_ip|
|
|
35
|
+
if allowed_ip.include?("/")
|
|
36
|
+
IPAddr.new(allowed_ip).include?(ip_address)
|
|
37
|
+
else
|
|
38
|
+
IPAddr.new(allowed_ip).include?(IPAddr.new(ip_address))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
rescue IPAddr::InvalidAddressError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/switchlet.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "switchlet/version"
|
|
4
|
+
require_relative "switchlet/flag"
|
|
5
|
+
require_relative "switchlet/configuration"
|
|
6
|
+
require_relative "switchlet/railtie" if defined?(Rails)
|
|
7
|
+
require_relative "switchlet/engine" if defined?(Rails)
|
|
8
|
+
|
|
9
|
+
module Switchlet
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
def self.configuration
|
|
13
|
+
@configuration ||= Configuration.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.configure
|
|
17
|
+
yield(configuration)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.enabled?(name)
|
|
21
|
+
Flag.find_by(name: name.to_s)&.enabled || false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.enable!(name)
|
|
25
|
+
flag = Flag.find_or_create_by(name: name.to_s)
|
|
26
|
+
flag.update!(enabled: true)
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.disable!(name)
|
|
31
|
+
flag = Flag.find_or_create_by(name: name.to_s)
|
|
32
|
+
flag.update!(enabled: false)
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.delete!(name)
|
|
37
|
+
Flag.where(name: name.to_s).delete_all
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.list
|
|
42
|
+
Flag.order(:name).map do |flag|
|
|
43
|
+
{
|
|
44
|
+
name: flag.name,
|
|
45
|
+
enabled: flag.enabled,
|
|
46
|
+
updated_at: flag.updated_at
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :switchlet do
|
|
4
|
+
desc "List all feature flags"
|
|
5
|
+
task list: :environment do
|
|
6
|
+
flags = Switchlet.list
|
|
7
|
+
if flags.empty?
|
|
8
|
+
puts "No feature flags found."
|
|
9
|
+
else
|
|
10
|
+
puts "Feature Flags:"
|
|
11
|
+
flags.each do |flag|
|
|
12
|
+
puts " #{flag[:name]}: #{flag[:enabled]} (updated: #{flag[:updated_at]})"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "Enable a feature flag"
|
|
18
|
+
task :enable, [:flag] => :environment do |_task, args|
|
|
19
|
+
flag_name = args[:flag]
|
|
20
|
+
abort "Please specify a flag name: rake switchlet:enable[flag_name]" if flag_name.blank?
|
|
21
|
+
|
|
22
|
+
result = Switchlet.enable!(flag_name)
|
|
23
|
+
puts "Flag '#{flag_name}' #{result ? 'enabled' : 'failed to enable'}."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
desc "Disable a feature flag"
|
|
27
|
+
task :disable, [:flag] => :environment do |_task, args|
|
|
28
|
+
flag_name = args[:flag]
|
|
29
|
+
abort "Please specify a flag name: rake switchlet:disable[flag_name]" if flag_name.blank?
|
|
30
|
+
|
|
31
|
+
result = Switchlet.disable!(flag_name)
|
|
32
|
+
puts "Flag '#{flag_name}' #{result ? 'already disabled' : 'disabled'}."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
desc "Delete a feature flag"
|
|
36
|
+
task :delete, [:flag] => :environment do |_task, args|
|
|
37
|
+
flag_name = args[:flag]
|
|
38
|
+
abort "Please specify a flag name: rake switchlet:delete[flag_name]" if flag_name.blank?
|
|
39
|
+
|
|
40
|
+
Switchlet.delete!(flag_name)
|
|
41
|
+
puts "Flag '#{flag_name}' deleted."
|
|
42
|
+
end
|
|
43
|
+
end
|
data/sig/switchlet.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: switchlet
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- komagata
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '6.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '6.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: sqlite3
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
description: Simple boolean feature flags stored in database for Rails 6.1+
|
|
55
|
+
email:
|
|
56
|
+
- komagata@gmail.com
|
|
57
|
+
executables: []
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- LICENSE.txt
|
|
62
|
+
- README.md
|
|
63
|
+
- Rakefile
|
|
64
|
+
- app/controllers/switchlet/application_controller.rb
|
|
65
|
+
- app/controllers/switchlet/flags_controller.rb
|
|
66
|
+
- app/views/switchlet/flags/index.html.erb
|
|
67
|
+
- config/routes.rb
|
|
68
|
+
- lib/generators/switchlet/install_generator.rb
|
|
69
|
+
- lib/generators/switchlet/templates/create_switchlet_flags.rb
|
|
70
|
+
- lib/generators/switchlet/templates/switchlet.rb
|
|
71
|
+
- lib/switchlet.rb
|
|
72
|
+
- lib/switchlet/configuration.rb
|
|
73
|
+
- lib/switchlet/engine.rb
|
|
74
|
+
- lib/switchlet/flag.rb
|
|
75
|
+
- lib/switchlet/railtie.rb
|
|
76
|
+
- lib/switchlet/version.rb
|
|
77
|
+
- lib/tasks/switchlet.rake
|
|
78
|
+
- sig/switchlet.rbs
|
|
79
|
+
homepage: https://github.com/komagata/switchlet
|
|
80
|
+
licenses:
|
|
81
|
+
- MIT
|
|
82
|
+
metadata:
|
|
83
|
+
allowed_push_host: https://rubygems.org
|
|
84
|
+
homepage_uri: https://github.com/komagata/switchlet
|
|
85
|
+
source_code_uri: https://github.com/komagata/switchlet
|
|
86
|
+
rdoc_options: []
|
|
87
|
+
require_paths:
|
|
88
|
+
- lib
|
|
89
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: 3.2.0
|
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '0'
|
|
99
|
+
requirements: []
|
|
100
|
+
rubygems_version: 3.7.1
|
|
101
|
+
specification_version: 4
|
|
102
|
+
summary: Minimal feature flag gem for Rails
|
|
103
|
+
test_files: []
|