mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 15:16:08 +08:00
working plugin interface for custom openid auth, custom css and custom js
This commit is contained in:
parent
61b330abb4
commit
160107a712
|
@ -16,19 +16,12 @@ Discourse.LoginController = Discourse.Controller.extend(Discourse.ModalFunctiona
|
|||
return Discourse.Site.instance();
|
||||
}.property(),
|
||||
|
||||
|
||||
/**
|
||||
Determines whether at least one login button is enabled
|
||||
**/
|
||||
hasAtLeastOneLoginButton: function() {
|
||||
return Discourse.SiteSettings.enable_google_logins ||
|
||||
Discourse.SiteSettings.enable_facebook_logins ||
|
||||
Discourse.SiteSettings.enable_cas_logins ||
|
||||
Discourse.SiteSettings.enable_twitter_logins ||
|
||||
Discourse.SiteSettings.enable_yahoo_logins ||
|
||||
Discourse.SiteSettings.enable_github_logins ||
|
||||
Discourse.SiteSettings.enable_persona_logins;
|
||||
}.property(),
|
||||
return Em.get("Discourse.LoginMethod.all").length > 0;
|
||||
}.property("Discourse.LoginMethod.all.@each"),
|
||||
|
||||
loginButtonText: function() {
|
||||
return this.get('loggingIn') ? I18n.t('login.logging_in') : I18n.t('login.title');
|
||||
|
@ -78,54 +71,30 @@ Discourse.LoginController = Discourse.Controller.extend(Discourse.ModalFunctiona
|
|||
|
||||
authMessage: (function() {
|
||||
if (this.blank('authenticate')) return "";
|
||||
return I18n.t("login." + (this.get('authenticate')) + ".message");
|
||||
var method = Discourse.get('LoginMethod.all').findProperty("name", this.get("authenticate"));
|
||||
if(method){
|
||||
return method.get('message');
|
||||
}
|
||||
}).property('authenticate'),
|
||||
|
||||
twitterLogin: function() {
|
||||
this.set('authenticate', 'twitter');
|
||||
var left = this.get('lastX') - 400;
|
||||
var top = this.get('lastY') - 200;
|
||||
return window.open(Discourse.getURL("/auth/twitter"), "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top);
|
||||
},
|
||||
externalLogin: function(loginMethod){
|
||||
var name = loginMethod.get("name");
|
||||
var customLogin = loginMethod.get("customLogin");
|
||||
|
||||
facebookLogin: function() {
|
||||
this.set('authenticate', 'facebook');
|
||||
var left = this.get('lastX') - 400;
|
||||
var top = this.get('lastY') - 200;
|
||||
return window.open(Discourse.getURL("/auth/facebook"), "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top);
|
||||
},
|
||||
|
||||
casLogin: function() {
|
||||
var left, top;
|
||||
this.set('authenticate', 'cas');
|
||||
left = this.get('lastX') - 400;
|
||||
top = this.get('lastY') - 200;
|
||||
return window.open("/auth/cas", "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top);
|
||||
},
|
||||
|
||||
openidLogin: function(provider) {
|
||||
var left = this.get('lastX') - 400;
|
||||
var top = this.get('lastY') - 200;
|
||||
if (provider === "yahoo") {
|
||||
this.set("authenticate", 'yahoo');
|
||||
return window.open(Discourse.getURL("/auth/yahoo"), "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top);
|
||||
if(customLogin){
|
||||
customLogin();
|
||||
} else {
|
||||
window.open(Discourse.getURL("/auth/google"), "_blank", "menubar=no,status=no,height=500,width=850,left=" + left + ",top=" + top);
|
||||
return this.set("authenticate", 'google');
|
||||
this.set('authenticate', name);
|
||||
var left = this.get('lastX') - 400;
|
||||
var top = this.get('lastY') - 200;
|
||||
|
||||
var height = loginMethod.get("frameHeight") || 400;
|
||||
var width = loginMethod.get("frameWidth") || 800;
|
||||
window.open(Discourse.getURL("/auth/" + name), "_blank",
|
||||
"menubar=no,status=no,height=" + height + ",width=" + width + ",left=" + left + ",top=" + top);
|
||||
}
|
||||
},
|
||||
|
||||
githubLogin: function() {
|
||||
this.set('authenticate', 'github');
|
||||
var left = this.get('lastX') - 400;
|
||||
var top = this.get('lastY') - 200;
|
||||
return window.open(Discourse.getURL("/auth/github"), "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top);
|
||||
},
|
||||
|
||||
personaLogin: function() {
|
||||
navigator.id.request();
|
||||
},
|
||||
|
||||
authenticationComplete: function(options) {
|
||||
if (options.awaiting_approval) {
|
||||
this.flash(I18n.t('login.awaiting_approval'), 'success');
|
||||
|
|
69
app/assets/javascripts/discourse/models/login_method.js
Normal file
69
app/assets/javascripts/discourse/models/login_method.js
Normal file
|
@ -0,0 +1,69 @@
|
|||
Discourse.LoginMethod = Ember.Object.extend({
|
||||
title: function(){
|
||||
return this.get("titleOverride") || I18n.t("login." + this.get("name") + ".title");
|
||||
}.property(),
|
||||
|
||||
message: function(){
|
||||
return this.get("messageOverride") || I18n.t("login." + this.get("name") + ".message");
|
||||
}.property()
|
||||
});
|
||||
|
||||
// Note, you can add login methods by adding to the list
|
||||
// just Em.get("Discourse.LoginMethod.all") and then
|
||||
// pushObject for any new methods
|
||||
Discourse.LoginMethod.reopenClass({
|
||||
register: function(method){
|
||||
if(this.methods){
|
||||
this.methods.pushObject(method);
|
||||
} else {
|
||||
this.preRegister = this.preRegister || [];
|
||||
this.preRegister.push(method);
|
||||
}
|
||||
},
|
||||
|
||||
all: function(){
|
||||
if (this.methods) { return this.methods; }
|
||||
|
||||
var methods = this.methods = Em.A();
|
||||
|
||||
/*
|
||||
* enable_google_logins etc.
|
||||
* */
|
||||
|
||||
[ "google",
|
||||
"facebook",
|
||||
"cas",
|
||||
"twitter",
|
||||
"yahoo",
|
||||
"github",
|
||||
"persona"
|
||||
].forEach(function(name){
|
||||
if(Discourse.SiteSettings["enable_" + name + "_logins"]){
|
||||
|
||||
var params = {name: name};
|
||||
|
||||
if(name === "persona") {
|
||||
params.customLogin = function(){
|
||||
navigator.id.request();
|
||||
};
|
||||
}
|
||||
|
||||
if(name === "google") {
|
||||
params.frameWidth = 850;
|
||||
params.frameHeight = 500;
|
||||
}
|
||||
|
||||
methods.pushObject(Discourse.LoginMethod.create(params));
|
||||
}
|
||||
});
|
||||
|
||||
if (this.preRegister){
|
||||
this.preRegister.forEach(function(method){
|
||||
methods.pushObject(method);
|
||||
});
|
||||
delete this.preRegister;
|
||||
}
|
||||
return methods;
|
||||
}.property()
|
||||
});
|
||||
|
|
@ -1,27 +1,9 @@
|
|||
<div class="modal-body">
|
||||
{{#if hasAtLeastOneLoginButton}}
|
||||
<div id="login-buttons">
|
||||
{{#if Discourse.SiteSettings.enable_google_logins}}
|
||||
<button class="btn btn-social google" title="{{i18n login.google.title}}" {{action openidLogin "google"}}>{{i18n login.google.title}}</button>
|
||||
{{/if}}
|
||||
{{#if Discourse.SiteSettings.enable_facebook_logins}}
|
||||
<button class="btn btn-social facebook" title="{{i18n login.facebook.title}}" {{action "facebookLogin"}}>{{i18n login.facebook.title}}</button>
|
||||
{{/if}}
|
||||
{{#if Discourse.SiteSettings.enable_cas_logins}}
|
||||
<button class="btn btn-social cas" title="{{i18n login.cas.title}}" {{action "casLogin"}}>{{i18n login.cas.title}}</button>
|
||||
{{/if}}
|
||||
{{#if Discourse.SiteSettings.enable_twitter_logins}}
|
||||
<button class="btn btn-social twitter" title="{{i18n login.twitter.title}}" {{action "twitterLogin"}}>{{i18n login.twitter.title}}</button>
|
||||
{{/if}}
|
||||
{{#if Discourse.SiteSettings.enable_yahoo_logins}}
|
||||
<button class="btn btn-social yahoo" title="{{i18n login.yahoo.title}}" {{action openidLogin "yahoo"}}>{{i18n login.yahoo.title}}</button>
|
||||
{{/if}}
|
||||
{{#if Discourse.SiteSettings.enable_github_logins}}
|
||||
<button class="btn btn-social github" title="{{i18n login.github.title}}" {{action "githubLogin"}}>{{i18n login.github.title}}</button>
|
||||
{{/if}}
|
||||
{{#if Discourse.SiteSettings.enable_persona_logins}}
|
||||
<button class="btn btn-social persona" title="{{i18n login.persona.title}}" {{action "personaLogin"}}>{{i18n login.persona.title}}</button>
|
||||
{{/if}}
|
||||
{{#each Discourse.LoginMethod.all}}
|
||||
<button class="btn btn-social {{unbound name}}" {{action externalLogin this}}>{{title}}</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if Discourse.SiteSettings.enable_local_logins}}
|
||||
|
|
|
@ -20,15 +20,28 @@ class Users::OmniauthCallbacksController < ApplicationController
|
|||
skip_before_filter :verify_authenticity_token, only: :complete
|
||||
|
||||
def complete
|
||||
# Make sure we support that provider
|
||||
provider = params[:provider]
|
||||
raise Discourse::InvalidAccess.new unless self.class.types.keys.map(&:to_s).include?(provider)
|
||||
|
||||
# Check if the provider is enabled
|
||||
raise Discourse::InvalidAccess.new("provider is not enabled") unless SiteSetting.send("enable_#{provider}_logins?")
|
||||
# If we are a plugin, then try to login with it
|
||||
found = false
|
||||
Discourse.auth_providers.each do |p|
|
||||
if p.name == provider && p.type == :open_id
|
||||
create_or_sign_on_user_using_openid request.env["omniauth.auth"]
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
# Call the appropriate logic
|
||||
send("create_or_sign_on_user_using_#{provider}", request.env["omniauth.auth"])
|
||||
unless found
|
||||
# Make sure we support that provider
|
||||
raise Discourse::InvalidAccess.new unless self.class.types.keys.map(&:to_s).include?(provider)
|
||||
|
||||
# Check if the provider is enabled
|
||||
raise Discourse::InvalidAccess.new("provider is not enabled") unless SiteSetting.send("enable_#{provider}_logins?")
|
||||
|
||||
# Call the appropriate logic
|
||||
send("create_or_sign_on_user_using_#{provider}", request.env["omniauth.auth"])
|
||||
end
|
||||
|
||||
@data[:awaiting_approval] = true if invite_only?
|
||||
|
||||
|
|
|
@ -119,6 +119,11 @@ module Discourse
|
|||
# attr_accessible.
|
||||
config.active_record.whitelist_attributes = false
|
||||
|
||||
unless Rails.env.test?
|
||||
require 'plugin'
|
||||
Discourse.activate_plugins!
|
||||
end
|
||||
|
||||
# So open id logs somewhere sane
|
||||
config.after_initialize do
|
||||
OpenID::Util.logger = Rails.logger
|
||||
|
@ -131,9 +136,6 @@ module Discourse
|
|||
Clockwork.run
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,8 +18,18 @@ Rails.application.config.middleware.use OmniAuth::Builder do
|
|||
:identifier => 'https://me.yahoo.com',
|
||||
:require => 'omniauth-openid'
|
||||
|
||||
# lambda is required for proper multisite support,
|
||||
# without it subdomains will not function correctly
|
||||
Discourse.auth_providers.each do |p|
|
||||
if p.type == :open_id
|
||||
provider :open_id, {
|
||||
:name => p.name,
|
||||
:store => OpenID::Store::Redis.new($redis),
|
||||
:require => 'omniauth-openid'
|
||||
}.merge(p.options)
|
||||
end
|
||||
end
|
||||
|
||||
# lambda is required for proper multisite support,
|
||||
# without it subdomains will not function correctly
|
||||
provider :facebook,
|
||||
:setup => lambda { |env|
|
||||
strategy = env['omniauth.strategy']
|
||||
|
|
4
lib/auth_provider.rb
Normal file
4
lib/auth_provider.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
class AuthProvider
|
||||
attr_accessor :type, :glyph, :background_color, :name, :title,
|
||||
:message, :frame_width, :frame_height, :options
|
||||
end
|
|
@ -23,6 +23,31 @@ module Discourse
|
|||
# Cross site request forgery
|
||||
class CSRF < Exception; end
|
||||
|
||||
def self.activate_plugins!
|
||||
@plugins = Plugin.find_all("#{Rails.root}/plugins")
|
||||
@plugins.each do |plugin|
|
||||
plugin.activate!
|
||||
end
|
||||
end
|
||||
|
||||
def self.plugins
|
||||
@plugins
|
||||
end
|
||||
|
||||
def self.auth_providers
|
||||
providers = nil
|
||||
if plugins
|
||||
plugins.each do |p|
|
||||
next unless p.auth_providers
|
||||
p.auth_providers.each do |prov|
|
||||
providers ||= []
|
||||
providers << prov
|
||||
end
|
||||
end
|
||||
end
|
||||
providers
|
||||
end
|
||||
|
||||
def self.cache
|
||||
@cache ||= Cache.new
|
||||
end
|
||||
|
|
|
@ -7,21 +7,22 @@ class DiscoursePluginRegistry
|
|||
attr_accessor :javascripts
|
||||
attr_accessor :server_side_javascripts
|
||||
attr_accessor :stylesheets
|
||||
|
||||
# Default accessor values
|
||||
#
|
||||
def stylesheets
|
||||
@stylesheets ||= Set.new
|
||||
end
|
||||
|
||||
def javascripts
|
||||
@javascripts ||= Set.new
|
||||
end
|
||||
|
||||
def server_side_javascripts
|
||||
@server_side_javascripts ||= Set.new
|
||||
end
|
||||
end
|
||||
|
||||
# Default accessor values
|
||||
#
|
||||
def self.stylesheets
|
||||
@stylesheets ||= Set.new
|
||||
end
|
||||
|
||||
def self.javascripts
|
||||
@javascripts ||= Set.new
|
||||
end
|
||||
|
||||
def self.server_side_javascripts
|
||||
@server_side_javascripts ||= Set.new
|
||||
end
|
||||
|
||||
def register_js(filename, options={})
|
||||
# If we have a server side option, add that too.
|
||||
|
|
177
lib/plugin.rb
Normal file
177
lib/plugin.rb
Normal file
|
@ -0,0 +1,177 @@
|
|||
require_dependency 'auth_provider'
|
||||
require 'digest/sha1'
|
||||
require 'fileutils'
|
||||
|
||||
class Plugin
|
||||
|
||||
METADATA = [:name, :about, :version, :authors]
|
||||
|
||||
attr_accessor :path
|
||||
attr_accessor *METADATA
|
||||
attr_reader :auth_providers
|
||||
attr_reader :assets
|
||||
|
||||
def self.find_all(parent_path)
|
||||
plugins = []
|
||||
Dir["#{parent_path}/**/plugin.rb"].each do |path|
|
||||
plugin = parse(File.read(path))
|
||||
plugin.path = path
|
||||
plugins << plugin
|
||||
end
|
||||
|
||||
plugins
|
||||
end
|
||||
|
||||
def self.parse(text)
|
||||
plugin = self.new
|
||||
|
||||
text.each_line do |line|
|
||||
break unless plugin.parse_line(line)
|
||||
end
|
||||
|
||||
plugin
|
||||
end
|
||||
|
||||
def initialize
|
||||
@assets = []
|
||||
end
|
||||
|
||||
def parse_line(line)
|
||||
line = line.strip
|
||||
|
||||
unless line.empty?
|
||||
return false unless line[0] == "#"
|
||||
attribute, *description = line[1..-1].split(":")
|
||||
|
||||
description = description.join(":")
|
||||
attribute = attribute.strip.to_sym
|
||||
|
||||
if METADATA.include?(attribute)
|
||||
self.send("#{attribute}=", description.strip)
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# will make sure all the assets this plugin needs are registered
|
||||
def generate_automatic_assets!
|
||||
paths = []
|
||||
automatic_assets.each do |path, contents|
|
||||
unless File.exists? path
|
||||
ensure_directory path
|
||||
File.open(path,"w") do |f|
|
||||
f.write(contents)
|
||||
end
|
||||
end
|
||||
paths << path
|
||||
end
|
||||
|
||||
delete_extra_automatic_assets(paths)
|
||||
|
||||
paths
|
||||
end
|
||||
|
||||
def delete_extra_automatic_assets(good_paths)
|
||||
filenames = good_paths.map{|f| File.basename(f)}
|
||||
# nuke old files
|
||||
Dir.foreach(auto_generated_path) do |p|
|
||||
next if [".", ".."].include?(p)
|
||||
next if filenames.include?(p)
|
||||
File.delete(auto_generated_path + "/#{p}")
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_directory(path)
|
||||
dirname = File.dirname(path)
|
||||
unless File.directory?(dirname)
|
||||
FileUtils.mkdir_p(dirname)
|
||||
end
|
||||
end
|
||||
|
||||
def auto_generated_path
|
||||
File.dirname(path) << "/auto_generated"
|
||||
end
|
||||
|
||||
def register_css(style)
|
||||
@styles ||= []
|
||||
@styles << style
|
||||
end
|
||||
|
||||
def register_javascript(js)
|
||||
@javascripts ||= []
|
||||
@javascripts << js
|
||||
end
|
||||
|
||||
def automatic_assets
|
||||
css = ""
|
||||
js = "(function(){"
|
||||
|
||||
css = @styles.join("\n") if @styles
|
||||
js = @javascripts.join("\n") if @javascripts
|
||||
|
||||
unless auth_providers.blank?
|
||||
auth_providers.each do |auth|
|
||||
overrides = ""
|
||||
overrides = ", titleOverride: '#{auth.title}'" if auth.title
|
||||
overrides << ", messageOverride: '#{auth.message}'" if auth.message
|
||||
overrides << ", frameWidth: '#{auth.frame_width}'" if auth.frame_width
|
||||
overrides << ", frameHeight: '#{auth.frame_height}'" if auth.frame_height
|
||||
|
||||
js << "Discourse.LoginMethod.register(Discourse.LoginMethod.create({name: '#{auth.name}'#{overrides}}));\n"
|
||||
|
||||
if auth.glyph
|
||||
css << ".btn-social.#{auth.name}:before{ content: '#{auth.glyph}'; }\n"
|
||||
end
|
||||
|
||||
if auth.background_color
|
||||
css << ".btn-social.#{auth.name}{ background: #{auth.background_color}; }\n"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
js << "})();"
|
||||
|
||||
# TODO don't serve blank assets
|
||||
[[css,"css"],[js,"js"]].map do |asset, extension|
|
||||
hash = Digest::SHA1.hexdigest asset
|
||||
["#{auto_generated_path}/plugin_#{hash}.#{extension}", asset]
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# note, we need to be able to parse seperately to activation.
|
||||
# this allows us to present information about a plugin in the UI
|
||||
# prior to activations
|
||||
def activate!
|
||||
self.instance_eval File.read(path)
|
||||
if auto_assets = generate_automatic_assets!
|
||||
assets.concat auto_assets
|
||||
end
|
||||
unless assets.blank?
|
||||
paths = []
|
||||
assets.each do |asset|
|
||||
if asset =~ /\.js$/
|
||||
DiscoursePluginRegistry.javascripts << asset
|
||||
elsif asset =~ /\.css$|\.scss$/
|
||||
DiscoursePluginRegistry.stylesheets << asset
|
||||
end
|
||||
paths << File.dirname(asset)
|
||||
end
|
||||
# TODO possibly amend this to a rails engine
|
||||
Rails.configuration.assets.paths << auto_generated_path
|
||||
Rails.configuration.assets.paths << File.dirname(path) + "/assets"
|
||||
end
|
||||
end
|
||||
|
||||
def auth_provider(type, opts)
|
||||
@auth_providers ||= []
|
||||
provider = AuthProvider.new
|
||||
provider.type = type
|
||||
[:name, :glyph, :background_color, :title, :message, :frame_width, :frame_height].each do |sym|
|
||||
provider.send "#{sym}=", opts.delete(sym)
|
||||
end
|
||||
provider.options = opts
|
||||
@auth_providers << provider
|
||||
end
|
||||
end
|
65
spec/components/plugin_spec.rb
Normal file
65
spec/components/plugin_spec.rb
Normal file
|
@ -0,0 +1,65 @@
|
|||
require 'spec_helper'
|
||||
require_dependency 'plugin'
|
||||
|
||||
describe Plugin do
|
||||
context "parse" do
|
||||
it "correctly parses plugin info" do
|
||||
plugin = Plugin.parse <<TEXT
|
||||
# name: plugin-name
|
||||
# about: about: my plugin
|
||||
# version: 0.1
|
||||
# authors: Frank Zappa
|
||||
|
||||
some_ruby
|
||||
TEXT
|
||||
|
||||
plugin.name.should == "plugin-name"
|
||||
plugin.about.should == "about: my plugin"
|
||||
plugin.version.should == "0.1"
|
||||
plugin.authors.should == "Frank Zappa"
|
||||
end
|
||||
end
|
||||
|
||||
context "find_all" do
|
||||
it "can find plugins correctly" do
|
||||
plugins = Plugin.find_all("#{Rails.root}/spec/fixtures/plugins")
|
||||
plugins.count.should == 1
|
||||
plugin = plugins[0]
|
||||
|
||||
plugin.name.should == "plugin-name"
|
||||
plugin.path.should == "#{Rails.root}/spec/fixtures/plugins/my_plugin/plugin.rb"
|
||||
end
|
||||
|
||||
it "does not blow up on missing directory" do
|
||||
plugins = Plugin.find_all("#{Rails.root}/frank_zappa")
|
||||
plugins.count.should == 0
|
||||
end
|
||||
end
|
||||
|
||||
context "activate!" do
|
||||
it "can activate plugins correctly" do
|
||||
plugin = Plugin.new
|
||||
plugin.path = "#{Rails.root}/spec/fixtures/plugins/my_plugin/plugin.rb"
|
||||
plugin.ensure_directory(plugin.auto_generated_path)
|
||||
|
||||
junk_file = "#{plugin.auto_generated_path}/junk"
|
||||
File.open("#{plugin.auto_generated_path}/junk", "w") {|f| f.write("junk")}
|
||||
plugin.activate!
|
||||
|
||||
plugin.auth_providers.count.should == 1
|
||||
auth_provider = plugin.auth_providers[0]
|
||||
auth_provider.options.should == {:identifier => 'https://zappa.com'}
|
||||
auth_provider.type.should == :open_id
|
||||
|
||||
# calls ensure_assets! make sure they are there
|
||||
plugin.assets.count.should == 2
|
||||
plugin.assets.each do |a|
|
||||
File.exists?(a).should be_true
|
||||
end
|
||||
|
||||
# ensure it cleans up all crap in autogenerated directory
|
||||
File.exists?(junk_file).should be_false
|
||||
end
|
||||
end
|
||||
|
||||
end
|
10
spec/fixtures/plugins/my_plugin/plugin.rb
vendored
Normal file
10
spec/fixtures/plugins/my_plugin/plugin.rb
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
# name: plugin-name
|
||||
# about: about: my plugin
|
||||
# version: 0.1
|
||||
# authors: Frank Zappa
|
||||
|
||||
auth_provider :open_id,
|
||||
:name => 'zappa',
|
||||
:identifier => 'https://zappa.com',
|
||||
:background_color => '#dd4814',
|
||||
:glyph => 'B'
|
Loading…
Reference in New Issue
Block a user