diff --git a/app/.meteor/packages b/app/.meteor/packages index 0208062b..0dd00e08 100644 --- a/app/.meteor/packages +++ b/app/.meteor/packages @@ -47,3 +47,4 @@ static-html aldeed:collection2@3.0.0 aldeed:schema-index akryum:vue-component +accounts-patreon diff --git a/app/.meteor/versions b/app/.meteor/versions index fe231134..7205fac5 100644 --- a/app/.meteor/versions +++ b/app/.meteor/versions @@ -2,6 +2,7 @@ accounts-base@1.6.0 accounts-google@1.3.3 accounts-oauth@1.2.0 accounts-password@1.6.0 +accounts-patreon@0.1.0 accounts-ui@1.3.1 accounts-ui-unstyled@1.4.2 akryum:npm-check@0.1.2 @@ -88,6 +89,7 @@ oauth2@1.3.0 observe-sequence@1.0.16 ongoworks:speakingurl@9.0.0 ordered-dict@1.1.0 +patreon-oauth@0.1.0 percolate:migrations@0.9.8 percolate:synced-cron@1.3.2 promise@0.11.2 diff --git a/app/imports/server/publications/users.js b/app/imports/server/publications/users.js index cf50d698..06fe0f3e 100644 --- a/app/imports/server/publications/users.js +++ b/app/imports/server/publications/users.js @@ -1,15 +1,18 @@ import '/imports/api/users/Users.js'; -Meteor.publish("user", function(){ +Meteor.publish('user', function(){ return Meteor.users.find(this.userId, {fields: { roles: 1, username: 1, apiKey: 1, darkMode: 1, + 'services.patreon.id': 1, + 'services.patreon.entitledCents': 1, + 'services.patreon.entitledCentsOverride': 1, }}); }); -Meteor.publish("userPublicProfiles", function(ids){ +Meteor.publish('userPublicProfiles', function(ids){ if (!this.userId || !Array.isArray(ids)) return []; return Meteor.users.find({ _id: {$in: ids} diff --git a/app/imports/ui/StoryBook.vue b/app/imports/ui/StoryBook.vue deleted file mode 100644 index 934c5232..00000000 --- a/app/imports/ui/StoryBook.vue +++ /dev/null @@ -1,100 +0,0 @@ - - - - - Storybook - - - - menu - - - - - Components - - - - - - {{componentName}} - - - - - - - - - - - - - - - - diff --git a/app/imports/ui/layouts/Sidebar.vue b/app/imports/ui/layouts/Sidebar.vue index 95fb9eef..8eecfac7 100644 --- a/app/imports/ui/layouts/Sidebar.vue +++ b/app/imports/ui/layouts/Sidebar.vue @@ -1,59 +1,77 @@ - - - This is an early build of DiceCloud version 2. Data will be erased - frequently. Don't store anything important here. - - - I won't - - - - Sign in - + + + This is an early build of DiceCloud version 2. Data will be erased + frequently. Don't store anything important here yet. + + + + I won't + + + + + + Sign in + + - - - - {{userName}} - + + + + {{ userName }} + - - - settings - Account Settings - + + + + settings + + Account Settings + - + - {{link.icon}} + {{ link.icon }} - {{link.title}} + {{ link.title }} - + - {{character.name}} + {{ character.name }} - {{party.name}} + {{ party.name }} - {{character.name}} + {{ character.name }} @@ -89,7 +107,7 @@ }}, meteor: { $subscribe: { - "characterList": [], + 'characterList': [], }, signedIn(){ return Meteor.userId(); @@ -100,16 +118,13 @@ }, links(){ let links = [ - {title: "Home", icon: "home", to: "/"}, - {title: "Characters", icon: "group", to: "/characterList", vif: Meteor.userId()}, - {title: "Library", icon: "book", to: "/library", vif: Meteor.userId()}, - {title: "Send Feedback", icon: "bug_report", to: "/feedback"}, - {title: "Patreon", icon: "", href: "https://www.patreon.com/dicecloud"}, - {title: "Github", icon: "", href: "https://github.com/ThaumRystra/DiceCloud/tree/version-2"}, + {title: 'Home', icon: 'home', to: '/'}, + {title: 'Characters', icon: 'group', to: '/characterList', vif: Meteor.userId()}, + {title: 'Library', icon: 'book', to: '/library', vif: Meteor.userId()}, + {title: 'Send Feedback', icon: 'bug_report', to: '/feedback'}, + {title: 'Patreon', icon: '', href: 'https://www.patreon.com/dicecloud'}, + {title: 'Github', icon: '', href: 'https://github.com/ThaumRystra/DiceCloud/tree/version-2'}, ]; - if (Meteor.settings.public.showStorybook || Meteor.isDevelopment){ - links.push({title: 'Component Previews', icon: "category", to: '/storybook/HealthBar'}) - } return links; }, parties(){ @@ -126,7 +141,7 @@ fields: {name: 1, urlName: 1}, } ).map(char => { - char.url = `\/character\/${char._id}\/${char.urlName || "-"}`; + char.url = `\/character\/${char._id}\/${char.urlName || '-'}`; return char; }); return party; @@ -143,7 +158,7 @@ }, {sort: {name: 1}} ).map(char => { - char.url = `\/character\/${char._id}\/${char.urlName || "-"}`; + char.url = `\/character\/${char._id}\/${char.urlName || '-'}`; return char; }); }, diff --git a/app/imports/ui/pages/Account.vue b/app/imports/ui/pages/Account.vue index edc834ce..90720efa 100644 --- a/app/imports/ui/pages/Account.vue +++ b/app/imports/ui/pages/Account.vue @@ -3,23 +3,34 @@ Account - + - - - + + + Username - {{user.username}} + {{ user && user.username }} Change Username - + create @@ -28,21 +39,29 @@ Email - + - {{email.address}} + {{ email.address }} - + Verified - assignment_turned_in + + assignment_turned_in + - + Verify Account assignment_late @@ -54,7 +73,11 @@ Add email address - + add @@ -63,24 +86,33 @@ API Key - + - {{user.apiKey}} + {{ user.apiKey }} - - {{"•".repeat(user.apiKey.length)}} + + {{ "•".repeat(user.apiKey.length) }} - - {{showApiKey ? 'visibility_off' : 'visibility'}} + + {{ showApiKey ? 'visibility_off' : 'visibility' }} - - + + Generate API Key + - - + + Sign Out @@ -115,28 +155,32 @@ diff --git a/app/imports/ui/pages/Home.vue b/app/imports/ui/pages/Home.vue index d2486fe7..2b2601b3 100644 --- a/app/imports/ui/pages/Home.vue +++ b/app/imports/ui/pages/Home.vue @@ -5,8 +5,16 @@ - - + + DiceCloud - Free, Auditable, real-time character tracking for 5th edition @@ -17,9 +25,22 @@ - - - money_off + + + + money_off + Free, open source, community funded @@ -28,8 +49,16 @@ and the source code is available on Github under a GPL license. - - ballot + + + ballot + Character sheets optimised for one ruleset @@ -38,8 +67,16 @@ does: being a fully automated character tracker - - scatter_plot + + + scatter_plot + Inventory manager @@ -51,9 +88,21 @@ - - - + + + Sign up @@ -62,29 +111,34 @@ Check out the example characters - + - - - Starter set archer - - + + + Starter set archer + + - - - Starter set wizard - - + + + Starter set wizard + + @@ -93,22 +147,48 @@ Get involved in the DiceCloud community - - + + Reddit - + Discord - + Patreon - + Github @@ -119,7 +199,7 @@ diff --git a/app/imports/ui/pages/PatreonLevelTooLow.vue b/app/imports/ui/pages/PatreonLevelTooLow.vue new file mode 100644 index 00000000..4b34b502 --- /dev/null +++ b/app/imports/ui/pages/PatreonLevelTooLow.vue @@ -0,0 +1,38 @@ + + + + Patreon tier not high enough + + + + Your current patreon support is ${{ entitledDollars }}. + + + You need to pledge at least $5 to use this beta. + + + + + + diff --git a/app/imports/ui/pages/SignIn.vue b/app/imports/ui/pages/SignIn.vue index e50d804d..80022be4 100644 --- a/app/imports/ui/pages/SignIn.vue +++ b/app/imports/ui/pages/SignIn.vue @@ -1,74 +1,112 @@ - - Sign In - - - - - - Reset Password - - {{error}} - - - - Sign In - - - Register - - - - - - - - {{googleError}} - - - Sign in with Google - - - + + + Sign In + + + + + + + + + + + + {{ patreonError }} + + + Sign in with Patreon + + + diff --git a/app/imports/ui/router.js b/app/imports/ui/router.js index 62746a28..c5ea765e 100644 --- a/app/imports/ui/router.js +++ b/app/imports/ui/router.js @@ -1,5 +1,4 @@ import { RouterFactory, nativeScrollBehavior } from 'meteor/akryum:vue-router2'; -import Vue from 'vue'; // Components import Home from '/imports/ui/pages/Home.vue'; @@ -10,6 +9,7 @@ import SignIn from '/imports/ui/pages/SignIn.vue' ; import Register from '/imports/ui/pages/Register.vue' ; import Account from '/imports/ui/pages/Account.vue' ; import NotImplemented from '/imports/ui/pages/NotImplemented.vue'; +import PatreonLevelTooLow from '/imports/ui/pages/PatreonLevelTooLow.vue'; // Not found import NotFound from '/imports/ui/pages/NotFound.vue'; @@ -21,6 +21,22 @@ const routerFactory = new RouterFactory({ scrollBehavior: nativeScrollBehavior, }); +function ensurePatronTier(to, from, next){ + let user = Meteor.user(); + if (!user){ + next('/sign-in'); + return; + } + let entitledCents = user.services.patreon.entitledCents || 0; + let overrideCents = user.services.patreon.entitledCentsOverride || 0; + + if (entitledCents < 500 && overrideCents < 500){ + next('/patreon-level-too-low'); + } else { + next(); + } +} + RouterFactory.configure(factory => { factory.addRoutes([ { @@ -30,48 +46,36 @@ RouterFactory.configure(factory => { },{ path: '/characterList', component: CharacterList, - //component: NotImplemented, + beforeEnter: ensurePatronTier, },{ path: '/library', component: Library, + beforeEnter: ensurePatronTier, },{ path: '/character/:id/:urlName', component: CharacterSheetPage, - //component: NotImplemented, + beforeEnter: ensurePatronTier, },{ path: '/character/:id', component: CharacterSheetPage, - //component: NotImplemented, - + beforeEnter: ensurePatronTier, },{ path: '/sign-in', component: SignIn, - },{ + },/*{ path: '/register', component: Register, - },{ + },*/{ path: '/account', component: Account, },{ path: '/feedback', component: NotImplemented, + },{ + path: '/patreon-level-too-low', + component: PatreonLevelTooLow, }, ]); - // Storybook routes - if (Meteor.settings.public.showStorybook || Meteor.isDevelopment){ - let StoryBook = require('/imports/ui/StoryBook.vue').default; - factory.addRoutes([ - { - path: '/storybook/:component', - name: 'componentStory', - component: StoryBook, - },{ - path: '/storybook', - name: 'storybook', - component: StoryBook, - }, - ]); - } // Icon admin routes if (Meteor.isDevelopment){ let IconAdmin = require('/imports/ui/icons/IconAdmin.vue').default; diff --git a/app/package.json b/app/package.json index 86123429..9f4df2ec 100644 --- a/app/package.json +++ b/app/package.json @@ -82,7 +82,8 @@ "env": { "es6": true, "browser": true, - "node": true + "node": true, + "meteor": true }, "rules": { "quotes": [ diff --git a/app/packages/accounts-patreon/.gitignore b/app/packages/accounts-patreon/.gitignore new file mode 100644 index 00000000..677a6fc2 --- /dev/null +++ b/app/packages/accounts-patreon/.gitignore @@ -0,0 +1 @@ +.build* diff --git a/app/packages/accounts-patreon/package.js b/app/packages/accounts-patreon/package.js new file mode 100644 index 00000000..4a925642 --- /dev/null +++ b/app/packages/accounts-patreon/package.js @@ -0,0 +1,17 @@ +Package.describe({ + summary: 'Login service for Patreon accounts', + version: '0.1.0', +}); + +Package.onUse(api => { + api.use('ecmascript'); + api.use('accounts-base', ['client', 'server']); + // Export Accounts (etc) to packages using this one. + api.imply('accounts-base', ['client', 'server']); + + api.use('accounts-oauth', ['client', 'server']); + api.use('patreon-oauth'); + api.imply('patreon-oauth'); + + api.addFiles('patreon.js'); +}); diff --git a/app/packages/accounts-patreon/patreon.js b/app/packages/accounts-patreon/patreon.js new file mode 100644 index 00000000..017a2618 --- /dev/null +++ b/app/packages/accounts-patreon/patreon.js @@ -0,0 +1,26 @@ +Accounts.oauth.registerService('patreon'); +console.log('accounts-patreon'); + +if (Meteor.isClient) { + const loginWithPatreon = (options, callback) => { + // support a callback without options + if (! callback && typeof options === 'function') { + callback = options; + options = null; + } + + const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); + Patreon.requestCredential(options, credentialRequestCompleteCallback); + }; + Accounts.registerClientLoginFunction('patreon', loginWithPatreon); + Meteor.loginWithPatreon = + (...args) => Accounts.applyLoginFunction('patreon', args); +} else { + Accounts.addAutopublishFields({ + // publish all fields including access token, which can legitimately + // be used from the client (if transmitted over ssl or on + // localhost). http://www.meetup.com/meetup_api/auth/#oauth2implicit + forLoggedInUser: ['services.patreon'], + forOtherUsers: ['services.patreon.id'] + }); +} diff --git a/app/packages/patreon-oauth/README.md b/app/packages/patreon-oauth/README.md new file mode 100644 index 00000000..ee15345f --- /dev/null +++ b/app/packages/patreon-oauth/README.md @@ -0,0 +1,3 @@ +# patreon-oauth + +An implementation of the Patreon OAuth flow. See the [Meteor Guide](https://guide.meteor.com/accounts.html) for more details. diff --git a/app/packages/patreon-oauth/package.js b/app/packages/patreon-oauth/package.js new file mode 100644 index 00000000..2ed1a5f7 --- /dev/null +++ b/app/packages/patreon-oauth/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: 'Patreon OAuth flow', + version: '0.1.0' +}); + +Package.onUse(api => { + api.use('ecmascript'); + api.use('oauth2', ['client', 'server']); + api.use('oauth', ['client', 'server']); + api.use('http', 'server'); + api.use('random', 'client'); + api.use('service-configuration', ['client', 'server']); + + api.addFiles('patreon_server.js', 'server'); + api.addFiles('patreon_client.js', 'client'); + + api.export('Patreon'); +}); diff --git a/app/packages/patreon-oauth/patreon_client.js b/app/packages/patreon-oauth/patreon_client.js new file mode 100644 index 00000000..be161908 --- /dev/null +++ b/app/packages/patreon-oauth/patreon_client.js @@ -0,0 +1,54 @@ +Patreon = {}; +console.log('patreon-oauth'); + +// Request Patreon credentials for the user +// @param options {optional} +// @param credentialRequestCompleteCallback {Function} Callback function to call on +// completion. Takes one argument, credentialToken on success, or Error on +// error. +Patreon.requestCredential = (options, credentialRequestCompleteCallback) => { + // support both (options, callback) and (callback). + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } + + const config = ServiceConfiguration.configurations.findOne({service: 'patreon'}); + if (!config) { + credentialRequestCompleteCallback && credentialRequestCompleteCallback( + new ServiceConfiguration.ConfigError()); + return; + } + + // For some reason, meetup converts underscores to spaces in the state + // parameter when redirecting back to the client, so we use + // `Random.id()` here (alphanumerics) instead of `Random.secret()` + // (base 64 characters). + const credentialToken = Random.id(); + + const scope = (options && options.requestPermissions) || [ + 'identity', + 'identity[email]', + ]; + const flatScope = scope.map(encodeURIComponent).join(' '); + //const flatScope = encodeURIComponent(scope.join(',')); + + console.log({flatScope}) + const loginStyle = OAuth._loginStyle('patreon', config, options); + + const loginUrl = + 'https://www.patreon.com/oauth2/authorize' + + `?client_id=${config.clientId}` + + '&response_type=code' + + (flatScope ? `&scope=${flatScope}` : '') + + `&redirect_uri=${OAuth._redirectUri('patreon', config)}` + + `&state=${OAuth._stateParam(loginStyle, credentialToken, options && options.redirectUrl)}`; + + OAuth.launchLogin({ + loginService: 'patreon', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + }); +}; diff --git a/app/packages/patreon-oauth/patreon_server.js b/app/packages/patreon-oauth/patreon_server.js new file mode 100644 index 00000000..8086acf3 --- /dev/null +++ b/app/packages/patreon-oauth/patreon_server.js @@ -0,0 +1,75 @@ +Patreon = {}; + +OAuth.registerService('patreon', 2, null, query => { + const response = getAccessToken(query); + const accessToken = response.access_token; + const refreshToken = response.refresh_token; + const scope = response.scope; + const expiresAt = (+new Date) + (1000 * response.expires_in); + const identity = getIdentity(accessToken); + let serviceData = { + id : identity.data.id, + email: identity.data.attributes.email, + entitledCents: identity.included[0] && + identity.included[0].attributes.currently_entitled_amount_cents || 0, + accessToken, + refreshToken, + scope, + expiresAt, + }; + return { serviceData }; +}); + +const getAccessToken = query => { + const config = ServiceConfiguration.configurations.findOne({service: 'patreon'}); + if (!config) + throw new ServiceConfiguration.ConfigError(); + + let response; + try { + response = HTTP.post( + 'https://www.patreon.com/api/oauth2/token', {headers: {Accept: 'application/json'}, params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + grant_type: 'authorization_code', + redirect_uri: OAuth._redirectUri('patreon', config), + }}); + } catch (err) { + throw Object.assign( + new Error(`Failed to complete OAuth handshake with Patreon. ${err.message}`), + { response: err.response } + ); + } + + if (response.data.error) { // if the http response was a json object with an error attribute + throw new Error(`Failed to complete OAuth handshake with Patreon. ${response.data.error}`); + } else { + return response.data; + } +}; + +const getIdentity = accessToken => { + try { + const response = HTTP.get( + 'https://www.patreon.com/api/oauth2/v2/identity?' + + 'fields%5Buser%5D=email&' + + 'fields%5Bmember%5D=currently_entitled_amount_cents&' + + 'include=memberships', + { + headers: {authorization: `Bearer ${accessToken}`}, + } + ); + let data = JSON.parse(response.content); + return data; + } catch (err) { + throw Object.assign( + new Error(`Failed to fetch identity from Patreon. ${err.message}`), + { response: err.response } + ); + } +}; + + +Patreon.retrieveCredential = (credentialToken, credentialSecret) => + OAuth.retrieveCredential(credentialToken, credentialSecret);