diff --git a/docs/API-Documentation.md b/docs/API-Documentation.md index 306f406c..e2657117 100644 --- a/docs/API-Documentation.md +++ b/docs/API-Documentation.md @@ -77,6 +77,7 @@ These JS APIs falls into the most common category and almost all apps, component - [Audio](API/fliplet-audio.md) (`fliplet-audio`) - [Audio Player](API/fliplet-audio-player.md) (`fliplet-audio-player`) + - [Auth](API/fliplet-auth.md) (`fliplet-auth`) - [Barcode](API/fliplet-barcode.md) (`fliplet-barcode`) - [Communicate](API/fliplet-communicate.md) (`fliplet-communicate`) - [Content](API/fliplet-content.md) (`fliplet-content`) diff --git a/docs/API/fliplet-auth.md b/docs/API/fliplet-auth.md new file mode 100644 index 00000000..56864aa5 --- /dev/null +++ b/docs/API/fliplet-auth.md @@ -0,0 +1,216 @@ +--- +description: Sign users into your Fliplet app with one line of code — email/password or SSO (Google, Microsoft, Apple). +--- + +# Auth JS APIs + +The Fliplet Auth JS APIs sign users into your Fliplet app with their existing Fliplet account. One call opens a popup, handles the authentication round-trip, and resolves with the signed-in user. Email/password and SSO (Google, Microsoft, Apple) are supported out of the box. + +Use these APIs to authenticate Fliplet users, read the signed-in user in your app code, sign users out, and get the auth token for custom API requests. + +## Before you start + +Add the `fliplet-auth` package to your app's dependencies. This exposes `Fliplet.Auth.*` at runtime. + +

Fliplet.Auth authenticates existing Fliplet user accounts. It does not create new accounts from inside an app — only Fliplet Studio's sign-up page can do that. A user who tries to sign in with an SSO provider (Google, Microsoft, Apple) that isn't linked to any Fliplet account sees an error asking them to sign up at Fliplet Studio first. Direct users to Fliplet Studio to create their account, then back to your app to sign in.

+ +`Fliplet.Auth` is the high-level API for signing users in and out — prefer it for app code. It is distinct from two other APIs that share related concepts: + +- **`Fliplet.User`** — low-level primitives (`getAuthToken`, `setAuthToken`, `getCachedSession`). `Fliplet.Auth` uses these internally; you rarely need to call them directly. +- **Data source login component** — an app-level authentication system that uses a data source as the user table. It's a separate system from `Fliplet.Auth` and is appropriate when you want users stored in your app's own data source rather than in Fliplet's user database. See [data source login](components/login.md) for that. + +## Methods + +### Sign a user in + +`Fliplet.Auth.signIn()` opens the unified sign-in popup and resolves with `{ user, token }` when the user successfully signs in. + +```js +Fliplet.Auth.signIn().then(function(result) { + // result.user: { id, email, firstName, lastName, userRoleId } + // result.token: auth token string, e.g. "eu--55a54008...-203-8965" + console.log('Signed in as', result.user.email); +}).catch(function(err) { + // surface the error to the user and let them retry + console.error(err.message); +}); +``` + +Pre-fill the email field in the sign-in form by passing `options.email`: + +```js +Fliplet.Auth.signIn({ email: 'alice@acme.com' }); +``` + +The promise **rejects with an `Error`** when: + +- The popup is blocked by the browser → `Error('Sign-in popup was blocked...')` +- The user closes the popup without completing sign-in → `Error('Sign-in was cancelled.')` +- Sign-in times out after 10 minutes → `Error('Sign-in timed out. Please try again.')` +- The API returns a sign-in error → `Error()` + +Always handle rejection in your app code — the examples on this page consistently do so. + +### Get the currently signed-in user + +`Fliplet.Auth.currentUser()` resolves to the signed-in user, or `null` when no user is signed in. + +```js +Fliplet.Auth.currentUser().then(function(user) { + if (!user) { + // not signed in + return; + } + + // user shape: { id, email, userRoleId, region, legacy } + console.log(user.email); +}); +``` + +The user object returned by `currentUser()` contains the fields that persist locally between sessions: `id`, `email`, `userRoleId`, `region`, and `legacy`. It does **not** include `firstName` / `lastName` (which `signIn()` returns) — to access those, either store them yourself after `signIn()` resolves, or query `v1/user` with `Fliplet.Auth.getToken()`. + +### Check whether a user is signed in + +`Fliplet.Auth.isSignedIn()` resolves to `true` or `false` — a convenience helper on top of `currentUser()`. + +```js +Fliplet.Auth.isSignedIn().then(function(isSignedIn) { + if (isSignedIn) { + // show authenticated UI + } +}); +``` + +### Get the user's auth token + +`Fliplet.Auth.getToken()` resolves to the signed-in user's auth token, or `null` when no user is signed in. Use it to authenticate calls to the Fliplet API as the signed-in user. + +

Only send the auth token to the Fliplet API. The token grants full access to this user's Fliplet account — treat it like a password. Never put it in the URL, log it, or send it to a third-party / non-Fliplet endpoint, as that hands the user's account to whoever receives it. To call your own backend, send a short-lived token your backend issues, not the Fliplet auth token.

+ +```js +Fliplet.Auth.getToken().then(function(token) { + if (!token) { + throw new Error('Not signed in'); + } + + return Fliplet.API.request({ + url: 'v1/user', + headers: { 'Auth-Token': token } + }); +}).then(function(response) { + console.log(response.user); +}); +``` + +`Fliplet.API.request()` is the recommended way to call the Fliplet API — it targets the correct API host for you and keeps the token on the Fliplet origin. + +### Sign the user out + +`Fliplet.Auth.signOut()` clears locally stored credentials and invalidates the server-side session. + +```js +Fliplet.Auth.signOut().then(function() { + // user is signed out; currentUser() now resolves to null +}); +``` + +If the server-side logout request fails (for example, the device is offline), local credentials are still cleared so the user is effectively signed out. + +### Listen for sign-in / sign-out events + +`Fliplet.Auth.onChange(callback)` subscribes to auth state changes. The callback fires with the new user after a successful sign-in, or with `null` after sign-out completes. Returns an unsubscribe function. + +```js +var unsubscribe = Fliplet.Auth.onChange(function(user) { + if (user) { + // user just signed in — refresh the UI as this user + } else { + // user just signed out — reset to logged-out state + } +}); + +// Stop listening when no longer needed +unsubscribe(); +``` + +

onChange fires only for sign-ins and sign-outs performed through Fliplet.Auth.signIn() and Fliplet.Auth.signOut(). It does not currently fire for auth changes made by other mechanisms (for example a Fliplet Login component on another screen), since those don't route through this API.

+ +## Examples + +### Gate a screen behind sign-in + +Prompt the user to sign in if they're not already signed in, then render the screen. + +```js +Fliplet.Auth.currentUser().then(function(user) { + if (user) { + return user; + } + + return Fliplet.Auth.signIn().then(function(result) { + return result.user; + }); +}).then(function(user) { + // render the screen as this user + document.querySelector('.greeting').textContent = 'Welcome, ' + user.email; +}).catch(function(err) { + // user cancelled sign-in or it failed + console.error(err.message); +}); +``` + +### Show the signed-in user's name in the header + +```js +Fliplet.Auth.currentUser().then(function(user) { + var greeting = user + ? 'Signed in as ' + user.email + : 'Not signed in'; + + document.querySelector('.profile-greeting').textContent = greeting; +}); +``` + +### Sign-out button + +```js +document.querySelector('.sign-out-button').addEventListener('click', function() { + Fliplet.Auth.signOut().then(function() { + location.reload(); + }); +}); +``` + +### Refresh the UI when auth state changes + +```js +Fliplet.Auth.onChange(function(user) { + if (user) { + document.body.classList.add('signed-in'); + } else { + document.body.classList.remove('signed-in'); + } +}); +``` + +### Make an authenticated API request + +```js +Fliplet.Auth.getToken().then(function(token) { + if (!token) { + throw new Error('Not signed in'); + } + + return Fliplet.API.request({ + url: 'v1/organizations', + headers: { 'Auth-Token': token } + }); +}).then(function(response) { + console.log('Organizations:', response.organizations); +}).catch(function(err) { + console.error(err); +}); +``` + +[Back to API documentation](../API-Documentation.md) +{: .buttons} diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 4b8fdf39..247359e7 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -203,6 +203,7 @@
  • Overview
  • Libraries

  • Core
  • +
  • Auth NEW
  • Communicate
  • Data Sources
  • Encryption
  • diff --git a/fliplet-login.js b/fliplet-login.js index 4ecdd423..763da043 100644 --- a/fliplet-login.js +++ b/fliplet-login.js @@ -1,6 +1,6 @@ require('colors'); -const _ = require('lodash'); +const url = require('url'); const auth = require('./lib/auth'); const config = require('./lib/config'); @@ -20,10 +20,31 @@ ${'Press Ctrl+C to exit.'.blue} const port = 9001; const baseUrl = `http://localhost:${port}`; -const redirectUrl = `${config.api_url}v1/auth/third-party?redirect=${encodeURIComponent(`${baseUrl}/callback`)}&responseType=code&source=CLI&title=Sign%20in%20to%20Authorize%20the%20Fliplet%20CLI`; +const callbackUrl = `${baseUrl}/callback`; + +// New unified sign-in URL — replaces the legacy /v1/auth/third-party flow. +// The page validates the callback URL against an allow-list (which already +// includes http://localhost:9001/callback) and on success navigates the +// browser to ?token=XXX&user=. +// +// No `&source=CLI`: the unified login page derives the session source from +// `isEmbedded` (views/login.pug) and does not read `?source=`, so passing it +// here would be a dead param that never tags the session. +const redirectUrl = `${config.api_url}v1/auth/login` + + `?return=callback` + + `&callback=${encodeURIComponent(callbackUrl)}`; require('http').createServer(function(req, res) { - if (req.url.match(/login/)) { + // Route on the parsed pathname, NOT a loose `req.url.match(/.../)`. The + // unified callback carries the signed-in user's data in the query string + // (`?token=...&user=`), so a substring match against the + // whole URL would misroute any user whose email or name contains "login" + // or "success" (e.g. john.login@acme.com, anyone named "Success") into the + // wrong branch — breaking their sign-in. Pathname matching is exact. + const parsed = url.parse(req.url, true); + const pathname = parsed.pathname; + + if (pathname === '/login') { res.writeHead(302, { 'Location': redirectUrl }); @@ -31,25 +52,36 @@ require('http').createServer(function(req, res) { return res.end(); } - if (req.url.match(/success/)) { + if (pathname === '/success') { res.end(`

    You have been logged in successfully to your Fliplet account.

    You may now close this window now and return to the terminal to continue the process.

    `); return process.exit(); } - if (req.url.match(/callback/)) { - const authToken = _.last(req.url.split('auth_token=')); + if (pathname === '/callback') { + // Token (and optional user payload) come from the callback query string. + // The unified contract delivers them as `?token=...&user=`. + const authToken = parsed.query.token; + + if (!authToken) { + console.error('No auth token received from the sign-in flow.'); + res.writeHead(400); + res.end('Missing token'); + return process.exit(1); + } auth.setUserForToken(authToken).then(function(user) { organizations.getOrganizationsList().then(function onGetOrganizations(organizations) { if (!organizations.length) { - return console.error('Your organization has not been found.'); - } - - if (organizations.length > 1) { - return console.error('You belong to multiple organizations.'); + console.error('Your organization has not been found.'); + res.writeHead(400); + res.end('No organizations available for this account.'); + return process.exit(1); } + // For users belonging to multiple organizations, default to the + // first one. They can switch later via `fliplet organization ` + // (use `fliplet list-organizations` to see all available IDs). const userOrganization = organizations[0]; config.set('organization', userOrganization); @@ -57,20 +89,31 @@ require('http').createServer(function(req, res) { console.log('----------------------------------------------------------\r\n'); console.log(`You have logged in successfully. Welcome back, ${user.fullName.yellow.underline}!`); console.log(`Your organization has been set to ${userOrganization.name.green.underline} (#${userOrganization.id}). Your account email is ${user.email.yellow.underline}.`); - console.log(`You can now develop and publish components via the ${'fliplet run'.bgBlack.red} command! -`); + + if (organizations.length > 1) { + console.log(`\n${'Note'.yellow}: you belong to ${organizations.length} organizations. To switch, run ${'fliplet list-organizations'.bgBlack.red} to see all of them and ${'fliplet organization '.bgBlack.red} to change.`); + } + + console.log(`\nYou can now develop and publish components via the ${'fliplet run'.bgBlack.red} command!\n`); res.writeHead(302, { 'Location': `${baseUrl}/success?${Date.now()}` }); return res.end(); + }).catch(function(err) { + console.error('Failed to fetch organizations:', err); + res.writeHead(500); + res.end('Failed to fetch organizations'); + return process.exit(1); }); }).catch(function(err) { console.error(err); - process.exit(); + res.writeHead(500); + res.end('Authentication failed'); + return process.exit(1); }); } -}).listen(9001); +}).listen(port); require('openurl').open(`${baseUrl}/login?${Date.now()}`); diff --git a/lib/auth.js b/lib/auth.js index d544b922..196d08fa 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -28,7 +28,8 @@ function login(options) { function setUserForToken(authToken) { return new Promise(function(resolve, reject) { request({ - url: config.api_url + 'v1/user?auth_token=' + authToken + url: config.api_url + 'v1/user', + headers: { 'Auth-Token': authToken } }, function(error, response, body) { if (error) { return reject({ error, response, body }); @@ -64,7 +65,8 @@ function isAdmin() { return new Promise(function(resolve, reject) { request({ method: 'GET', - url: `${config.api_url}v1/user?auth_token=${auth_token}` + url: `${config.api_url}v1/user`, + headers: { 'Auth-Token': auth_token } }, function(error, response, body) { if (error) { reject(error); diff --git a/lib/organizations.js b/lib/organizations.js index c2e64a71..24591f94 100644 --- a/lib/organizations.js +++ b/lib/organizations.js @@ -2,11 +2,15 @@ const request = require('request'); const config = require('./config'); var organizationsList; -var user = config.data.user || {}; -var auth_token = user.auth_token; function getOrganizationsListFromServer() { return new Promise(function(resolve, reject) { + // Read the auth token lazily — sign-in may have happened after + // this module was loaded, in which case the cached user object + // from module-init time would be stale. + var user = config.data.user || {}; + var auth_token = user.auth_token; + if (!auth_token) { return reject('You must login first with: fliplet login'); } @@ -15,7 +19,10 @@ function getOrganizationsListFromServer() { return resolve(organizationsList); } - request(config.api_url + 'v1/organizations?auth_token=' + auth_token, function(error, response, body) { + request({ + url: config.api_url + 'v1/organizations', + headers: { 'Auth-Token': auth_token } + }, function(error, response, body) { if (error) { return reject(error); } diff --git a/lib/publish.js b/lib/publish.js index 2356c392..3fdfb59f 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -62,7 +62,8 @@ function publish(user, opts = {}) { const options = { method: 'POST', json: true, - url: config.api_url + 'v1/widgets?auth_token=' + auth_token, + url: config.api_url + 'v1/widgets', + headers: { 'Auth-Token': auth_token }, formData: formData, timeout: 1000 * 60 * 5 // 5 minutes }; diff --git a/lib/template.js b/lib/template.js index cb810796..28899262 100644 --- a/lib/template.js +++ b/lib/template.js @@ -4,17 +4,19 @@ const request = require('request'); const config = require('./config'); function compile(options) { - let url = config.api_url + 'v1/widgets/compile'; + const url = config.api_url + 'v1/widgets/compile'; const user = config.get('user'); + const headers = {}; if (user && user.authToken) { - url += `?auth_token=${user.authToken}`; + headers['Auth-Token'] = user.authToken; } return new Promise(function(resolve, reject) { request({ method: 'POST', url, + headers, gzip: true, json: options }, function(error, response, body) { diff --git a/package-lock.json b/package-lock.json index 13b6d958..cacc83f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "fliplet-cli", - "version": "7.0.0", + "version": "7.0.1", "lockfileVersion": 1, "requires": true, "dependencies": {