0
0
Fork 1
Spiegel von https://github.com/paviliondev/discourse-custom-wizard.git synchronisiert 2024-11-25 02:30:28 +01:00

Update authentication and subscription handling

Dieser Commit ist enthalten in:
angusmcleod 2021-08-10 14:45:23 +08:00
Ursprung 247f7ca466
Commit a27c222dc6
24 geänderte Dateien mit 687 neuen und 8 gelöschten Zeilen

Datei anzeigen

@ -6,6 +6,7 @@ import I18n from "I18n";
const icons = { const icons = {
error: "times-circle", error: "times-circle",
success: "check-circle", success: "check-circle",
warn: "exclamation-circle",
info: "info-circle", info: "info-circle",
}; };

Datei anzeigen

@ -0,0 +1,47 @@
import Component from "@ember/component";
import CustomWizardPro from "../models/custom-wizard-pro";
import { notEmpty } from "@ember/object/computed";
import discourseComputed from "discourse-common/utils/decorators";
export default Component.extend({
classNameBindings: [':custom-wizard-pro-subscription', 'subscription.active:active:inactive'],
subscribed: notEmpty('subscription'),
@discourseComputed('subscription.type')
title(type) {
return type ?
I18n.t(`admin.wizard.pro.subscription.title.${type}`) :
I18n.t("admin.wizard.pro.not_subscribed");
},
@discourseComputed('subscription.active')
stateClass(active) {
return active ? 'active' : 'inactive';
},
@discourseComputed('stateClass')
stateLabel(stateClass) {
return I18n.t(`admin.wizard.pro.subscription.status.${stateClass}`);
},
actions: {
update() {
this.set('updating', true);
CustomWizardPro.update_subscription().then(result => {
if (result.success) {
this.setProperties({
updateIcon: 'check',
subscription: result.subscription
});
} else {
this.set('updateIcon', 'times');
}
}).finally(() => {
this.set('updating', false);
setTimeout(() => {
this.set('updateIcon', null);
}, 7000);
})
}
}
});

Datei anzeigen

@ -0,0 +1,56 @@
import Controller from "@ember/controller";
import discourseComputed from "discourse-common/utils/decorators";
import CustomWizardPro from "../models/custom-wizard-pro";
import { alias } from "@ember/object/computed";
export default Controller.extend({
messageUrl: "https://thepavilion.io/t/3652",
messageType: 'info',
messageKey: null,
showSubscription: alias('model.authentication.active'),
setup() {
const authentication = this.get('model.authentication');
const subscription = this.get('model.subscription');
const subscribed = subscription && subscription.active;
const authenticated = authentication && authentication.active;
if (!subscribed) {
this.set('messageKey', authenticated ? 'not_subscribed' : 'authorize');
} else {
this.set('messageKey', !authenticated ?
'subscription_expiring' :
subscribed ? 'subscription_active' : 'subscription_inactive'
);
}
},
@discourseComputed('model.server')
messageOpts(server) {
return { server };
},
actions: {
unauthorize() {
this.set('unauthorizing', true);
CustomWizardPro.unauthorize().then(result => {
if (result.success) {
this.setProperties({
messageKey: 'unauthorized',
messageType: 'warn',
"model.authentication": null,
"model.subscription": null
});
} else {
this.setProperties({
messageKey: 'unauthorize_failed',
messageType: 'error'
});
}
}).finally(() => {
this.set('unauthorizing', false);
})
}
}
});

Datei anzeigen

@ -43,12 +43,20 @@ export default {
} }
); );
this.route("adminWizardsLogs", { path: "/logs", resetNamespace: true }); this.route("adminWizardsLogs", {
path: "/logs",
resetNamespace: true
});
this.route("adminWizardsManager", { this.route("adminWizardsManager", {
path: "/manager", path: "/manager",
resetNamespace: true, resetNamespace: true,
}); });
this.route("adminWizardsPro", {
path: "/pro",
resetNamespace: true,
});
} }
); );
}, },

Datei anzeigen

@ -0,0 +1,34 @@
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import EmberObject from "@ember/object";
import DiscourseURL from "discourse/lib/url";
const CustomWizardPro = EmberObject.extend();
const basePath = "/admin/wizards/pro";
CustomWizardPro.reopenClass({
status() {
return ajax(basePath, {
type: "GET",
}).catch(popupAjaxError);
},
authorize() {
window.location.href = `${basePath}/authorize`;
},
unauthorize() {
return ajax(`${basePath}/authorize`, {
type: "DELETE",
}).catch(popupAjaxError);
},
update_subscription() {
return ajax(`${basePath}/subscription`, {
type: "POST",
}).catch(popupAjaxError);
}
});
export default CustomWizardPro;

Datei anzeigen

@ -221,7 +221,7 @@ CustomWizard.reopenClass({
const wizard = this._super.apply(this); const wizard = this._super.apply(this);
wizard.setProperties(buildProperties(wizardJson)); wizard.setProperties(buildProperties(wizardJson));
return wizard; return wizard;
}, }
}); });
export default CustomWizard; export default CustomWizard;

Datei anzeigen

@ -0,0 +1,20 @@
import CustomWizardPro from "../models/custom-wizard-pro";
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
model() {
return CustomWizardPro.status();
},
setupController(controller, model) {
console.log(model)
controller.set('model', model);
controller.setup();
},
actions: {
authorize() {
CustomWizardPro.authorize();
}
}
});

Datei anzeigen

@ -0,0 +1,31 @@
<div class="admin-wizard-controls">
<h3>{{i18n "admin.wizard.pro.title"}}</h3>
<div class="buttons">
{{#if model.authentication.active}}
{{conditional-loading-spinner size="small" condition=unauthorizing}}
<a {{action "unauthorize"}} title={{i18n "admin.wizard.pro.unauthorize"}}>
{{i18n "admin.wizard.pro.unauthorize"}}
</a>
<label>{{i18n "admin.wizard.pro.authorized"}}</label>
{{else}}
{{d-button
icon="id-card"
label="admin.wizard.pro.authorize"
action=(route-action "authorize")}}
{{/if}}
</div>
</div>
{{wizard-message
key=messageKey
url=messageUrl
type=messageType
opts=messageOpts
component="pro"}}
<div class="admin-wizard-container">
{{#if showSubscription}}
{{wizard-pro-subscription subscription=model.subscription}}
{{/if}}
</div>

Datei anzeigen

@ -7,6 +7,7 @@
{{/if}} {{/if}}
{{nav-item route="adminWizardsLogs" label="admin.wizard.log.nav_label"}} {{nav-item route="adminWizardsLogs" label="admin.wizard.log.nav_label"}}
{{nav-item route="adminWizardsManager" label="admin.wizard.manager.nav_label"}} {{nav-item route="adminWizardsManager" label="admin.wizard.manager.nav_label"}}
{{nav-item route="adminWizardsPro" label="admin.wizard.pro.nav_label"}}
{{/admin-nav}} {{/admin-nav}}
<div class="admin-container"> <div class="admin-container">

Datei anzeigen

@ -0,0 +1,30 @@
<div class="title-container">
<h3 class="subscription-title">{{title}}</h3>
<div class="buttons">
<span>
{{#if updating}}
{{loading-spinner size="small"}}
{{else if updateIcon}}
{{d-icon updateIcon}}
{{/if}}
</span>
{{d-button
icon="sync"
action=(action "update")
disabled=updating
label="admin.wizard.pro.subscription.update"}}
</div>
</div>
{{#if subscribed}}
<div class="detail-container">
<div class="subscription-state {{stateClass}}">{{stateLabel}}</div>
<div class="subscription-updated-at">
{{{i18n
'admin.wizard.pro.subscription.last_updated'
updated_at=(format-date subscription.updated_at leaveAgo="true")
}}}
</div>
</div>
{{/if}}

Datei anzeigen

@ -7,7 +7,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 20px; margin-bottom: 10px;
& + .wizard-message + div { & + .wizard-message + div {
margin-top: 20px; margin-top: 20px;
@ -715,3 +715,61 @@
width: 80px; width: 80px;
vertical-align: middle; vertical-align: middle;
} }
.admin-wizards-pro {
.admin-wizard-controls {
h3, label {
margin: 0;
}
label {
padding: .4em .5em;
margin-left: .75em;
background-color: $success;
color: $secondary;
}
.buttons {
display: flex;
align-items: center;
.loading-container {
margin-right: 1em;
}
}
}
.custom-wizard-pro-subscription {
.title-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: .5em;
h3 {
margin: 0;
}
.buttons > span {
margin-right: .5em;
}
}
.detail-container {
display: flex;
align-items: center;
padding: 1em;
background-color: $primary-very-low;
.subscription-state {
padding: .25em .5em;
margin-right: .75em;
&.active {
background-color: $success;
color: $secondary;
}
}
}
}
}

Datei anzeigen

@ -86,12 +86,20 @@ en:
no_file: Please choose a file to import no_file: Please choose a file to import
file_size_error: The file size must be 512kb or less file_size_error: The file size must be 512kb or less
file_format_error: The file must be a .json file file_format_error: The file must be a .json file
server_error: "Error: {{message}}"
importing: Importing wizards... importing: Importing wizards...
destroying: Destroying wizards... destroying: Destroying wizards...
import_complete: Import complete import_complete: Import complete
destroy_complete: Destruction complete destroy_complete: Destruction complete
pro:
documentation: Check out the PRO documentation
authorize: "Authorize this forum to use your PRO subscription plan on %{server}."
not_subscribed: "You've authorized, but are not currently subscribed to a PRO plan on %{server}."
subscription_expiring: "Your subscription is active, but will expire in the next 48 hours."
subscription_active: "Your subscription is active."
subscription_inactive: "Your subscription is inactive on this forum. Read more in <a href='https://thepavilion.io/t/3652'>the documentation</a>."
unauthorized: "You're unauthorized. If you have a subscription, it will become inactive in the next 48 hours."
unauthorize_failed: Failed to unauthorize.
editor: editor:
show: "Show" show: "Show"
hide: "Hide" hide: "Hide"
@ -424,7 +432,24 @@ en:
imported: imported imported: imported
upload: Select wizards.json upload: Select wizards.json
destroy: Destroy destroy: Destroy
destroyed: destroyed destroyed: destroyed
pro:
nav_label: PRO
title: Custom Wizard PRO
authorize: Authorize
authorized: Authorized
unauthorize: cancel
not_subscribed: You're not currently subscribed
subscription:
title:
community: Community Subscription
business: Business Subscription
status:
active: Active
inactive: Inactive
update: Update
last_updated: Last updated {{updated_at}}
wizard_js: wizard_js:
group: group:

Datei anzeigen

@ -43,5 +43,11 @@ Discourse::Application.routes.append do
get 'admin/wizards/manager/export' => 'admin_manager#export' get 'admin/wizards/manager/export' => 'admin_manager#export'
post 'admin/wizards/manager/import' => 'admin_manager#import' post 'admin/wizards/manager/import' => 'admin_manager#import'
delete 'admin/wizards/manager/destroy' => 'admin_manager#destroy' delete 'admin/wizards/manager/destroy' => 'admin_manager#destroy'
get 'admin/wizards/pro' => 'admin_pro#index'
get 'admin/wizards/pro/authorize' => 'admin_pro#authorize'
get 'admin/wizards/pro/authorize/callback' => 'admin_pro#authorize_callback'
delete 'admin/wizards/pro/authorize' => 'admin_pro#destroy'
post 'admin/wizards/pro/subscription' => 'admin_pro#update_subscription'
end end
end end

Datei anzeigen

@ -0,0 +1,46 @@
# frozen_string_literal: true
class CustomWizard::AdminProController < CustomWizard::AdminController
skip_before_action :check_xhr, :preload_json, :verify_authenticity_token, only: [:authorize, :authorize_callback]
def index
render_serialized(CustomWizard::Pro.new, CustomWizard::ProSerializer, root: false)
end
def authorize
request_id = SecureRandom.hex(32)
cookies[:user_api_request_id] = request_id
redirect_to CustomWizard::ProAuthentication.generate_request(current_user.id, request_id).to_s
end
def authorize_callback
payload = params[:payload]
request_id = cookies[:user_api_request_id]
CustomWizard::ProAuthentication.handle_response(request_id, payload)
CustomWizard::ProSubscription.update
redirect_to '/admin/wizards/pro'
end
def destroy
if CustomWizard::ProAuthentication.destroy
render json: success_json
else
render json: failed_json
end
end
def update_subscription
if CustomWizard::ProSubscription.update
render json: success_json.merge(
subscription: CustomWizard::ProSubscriptionSerializer.new(
CustomWizard::ProSubscription.new,
root: false
)
)
else
render json: failed_json
end
end
end

Datei anzeigen

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Jobs
class UpdateProSubscription < ::Jobs::Scheduled
every 1.days
def execute(args)
CustomWizard::ProSubscription.update
end
end
end

21
lib/custom_wizard/pro.rb Normale Datei
Datei anzeigen

@ -0,0 +1,21 @@
# frozen_string_literal: true
class CustomWizard::Pro
NAMESPACE ||= "#{CustomWizard::PLUGIN_NAME}_pro"
attr_reader :authentication,
:subscription
def initialize
@authentication = CustomWizard::ProAuthentication.new
@subscription = CustomWizard::ProSubscription.new
end
def authorized?
@authentication.active?
end
def subscribed?
@subscription.active?
end
end

Datei anzeigen

@ -0,0 +1,155 @@
class CustomWizard::ProAuthentication
include ActiveModel::Serialization
API_KEY ||= "api_key"
API_CLIENT_ID ||= 'api_client_id'
KEYS ||= "keys"
attr_reader :client_id,
:auth_by,
:auth_at,
:api_key
def initialize
api = get_api_key
@api_key = api.key
@auth_at = api.auth_at
@auth_by = api.auth_by
@client_id = get_client_id || set_client_id
end
def active?
@api_key.present?
end
def update(data)
api_key = data[:key]
user_id = data[:user_id]
user = User.find(user_id)
if user&.admin
set_api_key(api_key, user.id)
else
false
end
end
def destroy
remove
end
def self.destroy
self.new.destroy
end
def generate_keys(user_id, request_id)
rsa = OpenSSL::PKey::RSA.generate(2048)
nonce = SecureRandom.hex(32)
set_keys(request_id, user_id, rsa, nonce)
OpenStruct.new(nonce: nonce, public_key: rsa.public_key)
end
def decrypt_payload(request_id, payload)
keys = get_keys(request_id)
return false unless keys.present? && keys.pem
delete_keys(request_id)
rsa = OpenSSL::PKey::RSA.new(keys.pem)
decrypted_payload = rsa.private_decrypt(Base64.decode64(payload))
return false unless decrypted_payload.present?
begin
data = JSON.parse(decrypted_payload).symbolize_keys
rescue JSON::ParserError
return false
end
return false unless data[:nonce] == keys.nonce
data[:user_id] = keys.user_id
data
end
def self.generate_request(user_id, request_id)
authentication = self.new
keys = authentication.generate_keys(user_id, request_id)
params = {
public_key: keys.public_key,
nonce: keys.nonce,
client_id: authentication.client_id,
auth_redirect: "#{Discourse.base_url}/admin/wizards/pro/authorize/callback",
application_name: SiteSetting.title,
scopes: CustomWizard::ProSubscription::SCOPE
}
uri = URI.parse("https://#{CustomWizard::ProSubscription::SUBSCRIPTION_SERVER}/user-api-key/new")
uri.query = URI.encode_www_form(params)
uri.to_s
end
def self.handle_response(request_id, payload)
authentication = self.new
data = authentication.decrypt_payload(request_id, payload)
return unless data.is_a?(Hash) && data[:key] && data[:user_id]
authentication.update(data)
end
private
def get_api_key
raw = PluginStore.get(CustomWizard::Pro::NAMESPACE, API_KEY)
OpenStruct.new(
key: raw && raw['key'],
auth_by: raw && raw['auth_by'],
auth_at: raw && raw['auth_at']
)
end
def set_api_key(key, user_id)
PluginStore.set(CustomWizard::Pro::NAMESPACE, API_KEY,
key: key,
auth_by: user_id,
auth_at: Time.now
)
end
def remove
PluginStore.remove(CustomWizard::Pro::NAMESPACE, API_KEY)
end
def get_client_id
PluginStore.get(CustomWizard::Pro::NAMESPACE, API_CLIENT_ID)
end
def set_client_id
client_id = SecureRandom.hex(32)
PluginStore.set(CustomWizard::Pro::NAMESPACE, API_CLIENT_ID, client_id)
client_id
end
def set_keys(request_id, user_id, rsa, nonce)
PluginStore.set(CustomWizard::Pro::NAMESPACE, "#{KEYS}_#{request_id}",
user_id: user_id,
pem: rsa.export,
nonce: nonce
)
end
def get_keys(request_id)
raw = PluginStore.get(CustomWizard::Pro::NAMESPACE, "#{KEYS}_#{request_id}")
OpenStruct.new(
user_id: raw && raw['user_id'],
pem: raw && raw['pem'],
nonce: raw && raw['nonce']
)
end
def delete_keys(request_id)
PluginStore.remove(CustomWizard::Pro::NAMESPACE, "#{KEYS}_#{request_id}")
end
end

Datei anzeigen

@ -0,0 +1,74 @@
class CustomWizard::ProSubscription
include ActiveModel::Serialization
SUBSCRIPTION_SERVER ||= "test.thepavilion.io"
SUBSCRIPTION_TYPE ||= "stripe"
SCOPE ||= "discourse-subscription-server:user_subscription"
CLIENT_NAME ||= "custom-wizard"
SUBSCRIPTION_KEY ||= "custom_wizard_pro_subscription"
UPDATE_DAY_BUFFER ||= 2
TYPES ||= %w(community business)
attr_reader :type,
:updated_at
def initialize
raw = get
if raw
@type = raw['type']
@updated_at = raw['updated_at']
end
end
def active?
TYPES.include?(type) && updated_at.to_datetime > (Date.today - UPDATE_DAY_BUFFER.days).to_datetime
end
def update(data)
return false unless data && data.is_a?(Hash)
subscriptions = data[:subscriptions]
if subscriptions.present?
subscription = subscriptions.first
type = subscription[:price_nickname]
set(type)
end
end
def self.update
@subscribed = nil
auth = CustomWizard::ProAuthentication.new
subscription = self.new
if auth.active?
response = Excon.get(
"https://#{SUBSCRIPTION_SERVER}/subscription-server/user-subscriptions/#{SUBSCRIPTION_TYPE}/#{CLIENT_NAME}",
headers: { "User-Api-Key" => auth.api_key }
)
if response.status == 200
begin
data = JSON.parse(response.body).deep_symbolize_keys
rescue JSON::ParserError
return false
end
return subscription.update(data)
end
end
false
end
private
def set(type)
PluginStore.set(CustomWizard::Pro::NAMESPACE, SUBSCRIPTION_KEY, type: type, updated_at: Time.now)
end
def get
PluginStore.get(CustomWizard::Pro::NAMESPACE, SUBSCRIPTION_KEY)
end
end

Datei anzeigen

@ -62,11 +62,13 @@ after_initialize do
../controllers/custom_wizard/admin/logs.rb ../controllers/custom_wizard/admin/logs.rb
../controllers/custom_wizard/admin/manager.rb ../controllers/custom_wizard/admin/manager.rb
../controllers/custom_wizard/admin/custom_fields.rb ../controllers/custom_wizard/admin/custom_fields.rb
../controllers/custom_wizard/admin/pro.rb
../controllers/custom_wizard/wizard.rb ../controllers/custom_wizard/wizard.rb
../controllers/custom_wizard/steps.rb ../controllers/custom_wizard/steps.rb
../controllers/custom_wizard/realtime_validations.rb ../controllers/custom_wizard/realtime_validations.rb
../jobs/refresh_api_access_token.rb ../jobs/regular/refresh_api_access_token.rb
../jobs/set_after_time_wizard.rb ../jobs/regular/set_after_time_wizard.rb
../jobs/scheduled/update_pro_status.rb
../lib/custom_wizard/validators/template.rb ../lib/custom_wizard/validators/template.rb
../lib/custom_wizard/validators/update.rb ../lib/custom_wizard/validators/update.rb
../lib/custom_wizard/action_result.rb ../lib/custom_wizard/action_result.rb
@ -85,6 +87,9 @@ after_initialize do
../lib/custom_wizard/submission.rb ../lib/custom_wizard/submission.rb
../lib/custom_wizard/template.rb ../lib/custom_wizard/template.rb
../lib/custom_wizard/wizard.rb ../lib/custom_wizard/wizard.rb
../lib/custom_wizard/pro.rb
../lib/custom_wizard/pro/subscription.rb
../lib/custom_wizard/pro/authentication.rb
../lib/custom_wizard/api/api.rb ../lib/custom_wizard/api/api.rb
../lib/custom_wizard/api/authorization.rb ../lib/custom_wizard/api/authorization.rb
../lib/custom_wizard/api/endpoint.rb ../lib/custom_wizard/api/endpoint.rb
@ -105,6 +110,9 @@ after_initialize do
../serializers/custom_wizard/log_serializer.rb ../serializers/custom_wizard/log_serializer.rb
../serializers/custom_wizard/submission_serializer.rb ../serializers/custom_wizard/submission_serializer.rb
../serializers/custom_wizard/realtime_validation/similar_topics_serializer.rb ../serializers/custom_wizard/realtime_validation/similar_topics_serializer.rb
../serializers/custom_wizard/pro_serializer.rb
../serializers/custom_wizard/pro/authentication_serializer.rb
../serializers/custom_wizard/pro/subscription_serializer.rb
../extensions/extra_locales_controller.rb ../extensions/extra_locales_controller.rb
../extensions/invites_controller.rb ../extensions/invites_controller.rb
../extensions/users_controller.rb ../extensions/users_controller.rb

Datei anzeigen

@ -0,0 +1,11 @@
# frozen_string_literal: true
class CustomWizard::ProAuthenticationSerializer < ApplicationSerializer
attributes :active,
:client_id,
:auth_by,
:auth_at
def active
object.active?
end
end

Datei anzeigen

@ -0,0 +1,10 @@
# frozen_string_literal: true
class CustomWizard::ProSubscriptionSerializer < ApplicationSerializer
attributes :type,
:active,
:updated_at
def active
object.active?
end
end

Datei anzeigen

@ -0,0 +1,26 @@
# frozen_string_literal: true
class CustomWizard::ProSerializer < ApplicationSerializer
attributes :server,
:authentication,
:subscription
def server
CustomWizard::ProSubscription::SUBSCRIPTION_SERVER
end
def authentication
if object.authentication
CustomWizard::ProAuthenticationSerializer.new(object.authentication, root: false)
else
nil
end
end
def subscription
if object.subscription
CustomWizard::ProSubscriptionSerializer.new(object.subscription, root: false)
else
nil
end
end
end