diff --git a/src/backend/src/api/APIError.js b/src/backend/src/api/APIError.js index 70b9628dd..0aad9c1ff 100644 --- a/src/backend/src/api/APIError.js +++ b/src/backend/src/api/APIError.js @@ -51,6 +51,12 @@ module.exports = class APIError { status: 400, message: () => 'Invalid token' }, + 'unrecognized_offering': { + status: 400, + message: ({ name }) => { + return `offering ${quot(name)} was not recognized.`; + }, + }, // Things 'disallowed_thing': { status: 400, @@ -65,7 +71,7 @@ module.exports = class APIError { : '' ) + '.' }, - + // Unorganized 'item_with_same_name_exists': { status: 409, @@ -590,11 +596,11 @@ module.exports = class APIError { status: this.status, }; } - + querystringize (extra) { return new URLSearchParams(this.querystringize_(extra)); } - + querystringize_ (extra) { const fields = {}; for ( const k in this.fields ) { diff --git a/src/gui/src/UI/Components/JustID.js b/src/gui/src/UI/Components/JustID.js new file mode 100644 index 000000000..495108c23 --- /dev/null +++ b/src/gui/src/UI/Components/JustID.js @@ -0,0 +1,19 @@ +const Component = use('util.Component'); + +export default def(class JustID extends Component { + static ID = 'ui.component.JustID'; + static RENDER_MODE = Component.NO_SHADOW; + + static PROPERTIES = { + id: { value: undefined }, + } + + create_template ({ template }) { + const size = 24; + $(template).html(/*html*/` +
+ `); + } +}) diff --git a/src/gui/src/UI/Components/StepView.js b/src/gui/src/UI/Components/StepView.js index cc26373ee..af272c427 100644 --- a/src/gui/src/UI/Components/StepView.js +++ b/src/gui/src/UI/Components/StepView.js @@ -71,6 +71,21 @@ export default def(class StepView extends Component { // now that we're ready, show the wrapper $(this.dom_).find('#wrapper').show(); } + + add_child (child) { + const children = this.get('children'); + let pos = children.length; + child.setAttribute('slot', 'inside'); + $(child).hide(); + child.attach(this); + + return pos; + } + + display (child) { + const pos = this.add_child(child); + this.goto(pos); + } back () { if ( this.get('position') === 0 ) return; @@ -84,4 +99,8 @@ export default def(class StepView extends Component { } this.set('position', this.get('position') + 1); } + + goto (pos) { + this.set('position', pos); + } }); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 45118267a..e50046730 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -322,7 +322,7 @@ const en = { zipping: "Zipping %strong%", // === 2FA Setup === - setup2fa_1_step_heading: 'Open your authenticator app', + setup2fa_1_step_heading: 'Open your authenticator app', setup2fa_1_instructions: ` You can use any authenticator app that supports the Time-based One-Time Password (TOTP) protocol. There are many to choose from, but if you're unsure @@ -349,6 +349,12 @@ const en = { login2fa_use_recovery_code: 'Use a recovery code', login2fa_recovery_back: 'Back', login2fa_recovery_placeholder: 'XXXXXXXX', + + // Subscriptions + 'offering.free': 'Use Puter', + 'offering.pay-puter': 'Pay Puter', + 'offering.pay-puter-more': 'Pay Puter More', + 'offering.pay-puter-even-more': 'Pay Puter Even More', } }; diff --git a/src/gui/src/icons/subscription.svg b/src/gui/src/icons/subscription.svg new file mode 100644 index 000000000..e79177cc1 --- /dev/null +++ b/src/gui/src/icons/subscription.svg @@ -0,0 +1,2 @@ + + diff --git a/src/gui/src/init_async.js b/src/gui/src/init_async.js index f52e3f8c4..25a256563 100644 --- a/src/gui/src/init_async.js +++ b/src/gui/src/init_async.js @@ -21,12 +21,14 @@ logger.info('start -> async initialization'); import './util/TeePromise.js'; import './util/Component.js'; +import './util/Collector.js'; import './UI/Components/Frame.js'; import './UI/Components/Glyph.js'; import './UI/Components/Spinner.js'; import './UI/Components/ActionCard.js'; import './UI/Components/NotifCard.js'; import './UI/Components/TestView.js'; +import './UI/Components/JustID.js'; logger.info('end -> async initialization'); globalThis.init_promise.resolve(); diff --git a/src/gui/src/util/Collector.js b/src/gui/src/util/Collector.js new file mode 100644 index 000000000..91634ebd3 --- /dev/null +++ b/src/gui/src/util/Collector.js @@ -0,0 +1,66 @@ +const CollectorHandle = (key, collector) => ({ + async get (route) { + if ( collector.stored[key] ) return collector.stored[key]; + return await collector.fetch({ key, method: 'get', route }); + }, + async post (route, body) { + if ( collector.stored[key] ) return collector.stored[key]; + return await collector.fetch({ key, method: 'post', route, body }); + } +}) + +// TODO: link this with kv.js for expiration handling +export default def(class Collector { + constructor ({ origin, authToken }) { + this.origin = origin; + this.authToken = authToken; + this.stored = {}; + } + + to (name) { + return CollectorHandle(name, this); + } + + whats (key) { + return this.stored[key]; + } + + async get (route) { + return await this.fetch({ method: 'get', route }); + } + async post (route, body) { + return await this.fetch({ method: 'post', route, body }); + } + + discard (key) { + if ( ! key ) this.stored = {}; + delete this.stored[key]; + } + + async fetch (options) { + const fetchOptions = { + method: options.method, + headers: { + Authorization: `Bearer ${this.authToken}`, + 'Content-Type': 'application/json', + }, + }; + + if ( options.method === 'post' ) { + fetchOptions.body = JSON.stringify( + options.body ?? {}); + } + + const maybe_slash = options.route.startsWith('/') + ? '' : '/'; + + const resp = await fetch( + this.origin +maybe_slash+ options.route, + fetchOptions, + ); + const asJSON = await resp.json(); + + if ( options.key ) this.stored[options.key] = asJSON; + return asJSON; + } +}, 'util.Collector'); diff --git a/src/gui/src/util/Component.js b/src/gui/src/util/Component.js index 3ebfd0632..1bb4789b5 100644 --- a/src/gui/src/util/Component.js +++ b/src/gui/src/util/Component.js @@ -183,7 +183,10 @@ export const Component = def(class Component extends HTMLElement { this.dom_.appendChild(style); } if ( this.create_template ) { - this.create_template({ template }); + this.create_template({ + template, + content: template.content, + }); } const el = template.content.cloneNode(true); return el; @@ -202,7 +205,8 @@ export const Component = def(class Component extends HTMLElement { } this.values_[name].sub(callback); callback(this.values_[name].get(), {}); - } + }, + dom: this.dom_, }; } }); diff --git a/src/gui/src/util/Placeholder.js b/src/gui/src/util/Placeholder.js index 65af08719..a25083a89 100644 --- a/src/gui/src/util/Placeholder.js +++ b/src/gui/src/util/Placeholder.js @@ -27,13 +27,13 @@ /** * Placeholder creates a simple element with a unique ID * as an HTML string. - * + * * This can be useful where string concatenation is used * to build element trees. - * + * * The `replaceWith` method can be used to replace the * placeholder with a real element. - * + * * @returns {PlaceholderReturn} */ const Placeholder = def(() => { @@ -49,7 +49,7 @@ const Placeholder = def(() => { }; }, 'util.Placeholder'); -const anti_collision = `94d2cb6b85a1`; // Arbitrary random string +const anti_collision = `a4d2cb6b85a1`; // Arbitrary random string Placeholder.next_id_ = 0; Placeholder.get_next_id_ = () => `${anti_collision}_${Placeholder.next_id_++}`;