@linktr.ee/create-link-app 2.2.1 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -173,14 +173,14 @@ DESCRIPTION
173
173
 
174
174
  ## `create-link-app logout`
175
175
 
176
- Logout and clear browser session
176
+ Logout and clear local credentials
177
177
 
178
178
  ```
179
179
  USAGE
180
180
  $ create-link-app logout
181
181
 
182
182
  DESCRIPTION
183
- Logout and clear browser session
183
+ Logout and clear local credentials
184
184
  ```
185
185
 
186
186
  ## `create-link-app storybook`
@@ -1,37 +1,128 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
2
25
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
26
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
27
  };
5
28
  Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.loginCommand = void 0;
6
30
  const core_1 = require("@oclif/core");
31
+ const p = __importStar(require("@clack/prompts"));
32
+ const axios_1 = __importDefault(require("axios"));
33
+ const open_1 = __importDefault(require("open"));
34
+ const picocolors_1 = __importDefault(require("picocolors"));
7
35
  const base_1 = __importDefault(require("../base"));
8
- const access_token_1 = require("../lib/auth/access-token");
36
+ const fetch_app_config_1 = __importDefault(require("../lib/fetch-app-config"));
9
37
  const device_auth_1 = require("../lib/auth/device-auth");
38
+ const access_token_1 = require("../lib/auth/access-token");
39
+ const write_line_1 = require("../lib/utils/write-line");
40
+ function fixVerificationUrl(url, correctProjectId) {
41
+ try {
42
+ const parsed = new URL(url);
43
+ parsed.pathname = parsed.pathname.replace(/^\/login\/[A-Za-z0-9]+/, `/login/${correctProjectId}`);
44
+ return parsed.toString();
45
+ }
46
+ catch {
47
+ return url;
48
+ }
49
+ }
50
+ async function getDescopeHostedConfigStatus(projectId) {
51
+ const hostedConfigUrl = `https://api.descope.com/pages/${projectId}/v2-beta/config.json`;
52
+ try {
53
+ const response = await axios_1.default.get(hostedConfigUrl, {
54
+ validateStatus: () => true,
55
+ });
56
+ return response.status;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ async function loginCommand(options) {
63
+ const env = options.qa ? 'qa' : 'production';
64
+ p.intro(picocolors_1.default.cyan('Login to Linktree'));
65
+ const s = p.spinner();
66
+ s.start('Fetching configuration');
67
+ const config = await (0, fetch_app_config_1.default)(env);
68
+ s.stop('Configuration loaded');
69
+ // Initiate device authorization
70
+ s.start('Starting device authorization');
71
+ const handle = await (0, device_auth_1.initDeviceAuthorization)(config.auth);
72
+ s.stop('Device authorization initiated');
73
+ const { expires_in, user_code, verification_uri, verification_uri_complete } = handle;
74
+ const expiryMinutes = Math.floor(expires_in / 60);
75
+ // The device authorization response may contain a project ID in the URL path that differs
76
+ // from config.auth.project_id (e.g. when overridden via env var or hardcoded fallback).
77
+ // Replace it so the browser opens the correct hosted login flow.
78
+ const fixedUri = config.auth.project_id ? fixVerificationUrl(verification_uri, config.auth.project_id) : verification_uri;
79
+ const fixedUriComplete = config.auth.project_id
80
+ ? fixVerificationUrl(verification_uri_complete, config.auth.project_id)
81
+ : verification_uri_complete;
82
+ (0, write_line_1.writeLine)();
83
+ (0, write_line_1.writeLine)(`${picocolors_1.default.green('✓ Authorization code:')} ${picocolors_1.default.bold(user_code)}`);
84
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(` ${fixedUriComplete}`));
85
+ if (fixedUri && fixedUri !== fixedUriComplete) {
86
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(` ${fixedUri}`));
87
+ }
88
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(` Expires in ${expiryMinutes} minutes`));
89
+ (0, write_line_1.writeLine)();
90
+ // Open browser first so the user can start authenticating immediately
91
+ await (0, open_1.default)(fixedUriComplete);
92
+ (0, write_line_1.writeLine)(picocolors_1.default.cyan('🌐 Browser opened for authentication'));
93
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(' If browser did not open, visit the link above'));
94
+ (0, write_line_1.writeLine)();
95
+ // Fire-and-forget diagnostic check — don't block the auth flow
96
+ const projectId = config.auth.project_id;
97
+ if (projectId) {
98
+ void getDescopeHostedConfigStatus(projectId).then((status) => {
99
+ if (status && status >= 400) {
100
+ (0, write_line_1.writeLine)(picocolors_1.default.yellow('⚠ Descope hosted login may be misconfigured for this project.'));
101
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(` https://api.descope.com/pages/${projectId}/v2-beta/config.json returned HTTP ${status}`));
102
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(' This usually means an outdated auth project_id or missing Descope flow hosting config.'));
103
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(' Ask your platform team to set auth.project_id in create-link-app.json, or set LINKAPP_DESCOPE_[QA|PRODUCTION]_PROJECT_ID.'));
104
+ (0, write_line_1.writeLine)();
105
+ }
106
+ });
107
+ }
108
+ // Poll for token
109
+ s.start('Waiting for authentication');
110
+ const token = await (0, device_auth_1.pollAccessToken)(handle);
111
+ s.stop('Authentication successful');
112
+ if ((0, access_token_1.isTokenValid)(token)) {
113
+ (0, access_token_1.saveAccessToken)(token.access_token);
114
+ (0, write_line_1.writeLine)(picocolors_1.default.green('✓ Login successful'));
115
+ if (token.expires_at) {
116
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(` Token expires at ${new Date(token.expires_at * 1000).toString()}`));
117
+ }
118
+ }
119
+ p.outro(picocolors_1.default.green('You are now logged in'));
120
+ }
121
+ exports.loginCommand = loginCommand;
10
122
  class Login extends base_1.default {
11
123
  async run() {
12
124
  const { flags } = await this.parse(Login);
13
- this.log('🔐 Initiating login with Linktree credentials...');
14
- const appConfig = await this.getAppConfig(flags.qa ? 'qa' : 'production');
15
- const handle = await (0, device_auth_1.initDeviceAuthorization)(appConfig.auth);
16
- const { expires_in, user_code, verification_uri_complete } = handle;
17
- const expiryTime = expires_in % 60 === 0 ? `${expires_in / 60} minutes` : `${expires_in} seconds`;
18
- this.log(`🌐 Please login via the opened web browser link. The browser window should display the following code: ${user_code}`);
19
- this.log(`🔗 If browser does not open automatically, please go to the following link: ${verification_uri_complete}`);
20
- this.log(`⏰ This link expires in ${expiryTime}. Press Ctrl-C to abort.`);
21
- core_1.CliUx.ux.open(verification_uri_complete);
22
- core_1.CliUx.ux.action.start('⏳ Waiting for user to authorize device from browser');
23
- const token = await (0, device_auth_1.pollAccessToken)(handle);
24
- core_1.CliUx.ux.action.stop();
25
- if ((0, access_token_1.isTokenValid)(token)) {
26
- (0, access_token_1.saveAccessToken)(token.access_token);
27
- if (flags.qa) {
28
- this.log('🔑 TOKEN: ', token.access_token);
29
- }
30
- this.log('✅ Device has been authorized. Login successful.');
31
- if (token.expires_at) {
32
- this.log(`⏰ Login will expire at ${new Date(token.expires_at * 1000).toString()}`);
33
- }
34
- }
125
+ await loginCommand({ qa: flags.qa });
35
126
  }
36
127
  }
37
128
  exports.default = Login;
@@ -3,26 +3,47 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.logoutCommand = void 0;
6
7
  const core_1 = require("@oclif/core");
8
+ const open_1 = __importDefault(require("open"));
9
+ const picocolors_1 = __importDefault(require("picocolors"));
7
10
  const base_1 = __importDefault(require("../base"));
11
+ const fetch_app_config_1 = __importDefault(require("../lib/fetch-app-config"));
8
12
  const access_token_1 = require("../lib/auth/access-token");
13
+ const write_line_1 = require("../lib/utils/write-line");
14
+ async function logoutCommand(options) {
15
+ const env = options.qa ? 'qa' : 'production';
16
+ try {
17
+ const config = await (0, fetch_app_config_1.default)(env);
18
+ const { audience } = config.auth;
19
+ (0, access_token_1.removeAccessToken)(audience);
20
+ (0, write_line_1.writeLine)(picocolors_1.default.green('✓ Logged out successfully'));
21
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(' Local credentials have been removed'));
22
+ if (config.logout_redirect_url) {
23
+ try {
24
+ await (0, open_1.default)(config.logout_redirect_url);
25
+ (0, write_line_1.writeLine)(picocolors_1.default.cyan('🌐 Browser opened to complete provider sign-out'));
26
+ }
27
+ catch {
28
+ (0, write_line_1.writeLine)(picocolors_1.default.yellow('! Browser could not be opened automatically'));
29
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(` Complete sign-out here: ${config.logout_redirect_url}`));
30
+ }
31
+ }
32
+ (0, write_line_1.writeLine)();
33
+ }
34
+ catch (error) {
35
+ console.error(picocolors_1.default.red('✗ Logout failed:'), error);
36
+ }
37
+ }
38
+ exports.logoutCommand = logoutCommand;
9
39
  class Logout extends base_1.default {
10
40
  async run() {
11
41
  const { flags } = await this.parse(Logout);
12
- this.log('🔓 Logging out and clearing browser session...');
13
- const appConfig = await this.getAppConfig(flags.qa ? 'qa' : 'production');
14
- const { auth, logout_redirect_url } = appConfig;
15
- const { domain, client_id, audience } = auth;
16
- (0, access_token_1.removeAccessToken)(audience);
17
- // clear Auth0 session cookies so user gets prompted to enter credentials again for next login
18
- const url = `${domain}/v2/logout?client_id=${client_id}&returnTo=${logout_redirect_url}`;
19
- core_1.CliUx.ux.open(url);
20
- this.log('✅ Logout successful.');
21
- this.log(`🌐 If browser did not open automatically, please go to the following link to logout from the browser: ${url}`);
42
+ await logoutCommand({ qa: flags.qa });
22
43
  }
23
44
  }
24
45
  exports.default = Logout;
25
- Logout.description = 'Logout and clear browser session';
46
+ Logout.description = 'Logout and clear local credentials';
26
47
  Logout.flags = {
27
48
  qa: core_1.Flags.boolean({
28
49
  description: 'Use QA environment. Admin use only.',
@@ -7,6 +7,16 @@ exports.getAccessToken = exports.removeAccessToken = exports.saveAccessToken = e
7
7
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
8
8
  const netrc_parser_1 = __importDefault(require("netrc-parser"));
9
9
  const TokenExpiryThresholdSeconds = 60; // Used to consider tokens as 'expired' within the last x seconds of its actual expiry to ensure slow requests still use a valid token
10
+ function normalizeClaimValues(claim) {
11
+ if (!claim) {
12
+ return [];
13
+ }
14
+ const values = Array.isArray(claim) ? claim : [claim];
15
+ return values
16
+ .flatMap((value) => value.split(/[,\s]+/))
17
+ .map((value) => value.trim())
18
+ .filter((value) => value.length > 0);
19
+ }
10
20
  function isTokenValid(token) {
11
21
  if (!token.access_token || token.token_type !== 'Bearer') {
12
22
  throw new Error('Received access token is invalid');
@@ -15,9 +25,13 @@ function isTokenValid(token) {
15
25
  if (!payload) {
16
26
  throw new Error('There was an error parsing the access token, please try logging in again');
17
27
  }
18
- const permissions = payload.permissions ?? [];
28
+ const permissions = normalizeClaimValues(payload.permissions);
19
29
  if (permissions.length === 0) {
20
- throw new Error('Login request was successful, but the authenticated user does not have appropriate permissions to view or modify Link Apps');
30
+ // Descope and other providers may encode permissions in scope-like claims.
31
+ const scopePermissions = [...normalizeClaimValues(payload.scope), ...normalizeClaimValues(payload.scp)].filter((v) => /[:.]/u.test(v));
32
+ if (scopePermissions.length === 0) {
33
+ throw new Error('Login request was successful, but the authenticated user does not have appropriate permissions to view or modify Link Apps');
34
+ }
21
35
  }
22
36
  return true;
23
37
  }
@@ -53,15 +67,13 @@ function getAccessToken(audience) {
53
67
  if (!tokenString) {
54
68
  throw new Error('Not logged in. Please login using command: login');
55
69
  }
56
- else {
57
- const token = jsonwebtoken_1.default.decode(tokenString, { json: true });
58
- if (!token?.exp) {
59
- throw new Error('Cached login token is invalid. Please login again using command: login');
60
- }
61
- else if (token.exp - TokenExpiryThresholdSeconds < Math.floor(Date.now() / 1000)) {
62
- throw new Error('Login expired. Please login again using command: login');
63
- }
64
- return tokenString;
70
+ const token = jsonwebtoken_1.default.decode(tokenString, { json: true });
71
+ if (!token?.exp) {
72
+ throw new Error('Cached login token is invalid. Please login again using command: login');
73
+ }
74
+ if (token.exp - TokenExpiryThresholdSeconds < Math.floor(Date.now() / 1000)) {
75
+ throw new Error('Login expired. Please login again using command: login');
65
76
  }
77
+ return tokenString;
66
78
  }
67
79
  exports.getAccessToken = getAccessToken;
@@ -3,13 +3,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.pollAccessToken = exports.initDeviceAuthorization = void 0;
4
4
  const openid_client_1 = require("openid-client");
5
5
  async function initDeviceAuthorization(config) {
6
- const { domain, client_id, audience } = config;
7
- const auth0Issuer = await openid_client_1.Issuer.discover(domain);
8
- const client = new auth0Issuer.Client({
6
+ const { domain, client_id, audience, project_id } = config;
7
+ if (!project_id) {
8
+ throw new Error('Authentication configuration is missing the project_id. Please update to the latest CLI version and try again.');
9
+ }
10
+ const issuer = new openid_client_1.Issuer({
11
+ issuer: `${domain}/v1/apps/customized/${project_id}`,
12
+ device_authorization_endpoint: `${domain}/oauth2/v1/apps/${project_id}/device`,
13
+ token_endpoint: `${domain}/oauth2/v1/apps/${project_id}/token`,
14
+ jwks_uri: `${domain}/${project_id}/.well-known/jwks.json`,
15
+ });
16
+ const client = new issuer.Client({
9
17
  client_id,
10
18
  token_endpoint_auth_method: 'none',
11
19
  });
12
- return await client.deviceAuthorization({ scope: '', audience });
20
+ return client.deviceAuthorization({ scope: 'openid', audience });
13
21
  }
14
22
  exports.initDeviceAuthorization = initDeviceAuthorization;
15
23
  async function pollAccessToken(handle) {
@@ -4,15 +4,33 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const axios_1 = __importDefault(require("axios"));
7
+ const PROJECT_IDS = {
8
+ 'https://ciam.qa.linktr.ee': 'P32AChDvEswJtnBOsX26vHeCXTws',
9
+ 'https://ciam.linktr.ee': 'P32ACVpudk8MNftmpf1LR1pW5k8s',
10
+ };
11
+ const PROJECT_ID_ENV_VARS = {
12
+ qa: 'LINKAPP_DESCOPE_QA_PROJECT_ID',
13
+ production: 'LINKAPP_DESCOPE_PRODUCTION_PROJECT_ID',
14
+ };
7
15
  const fetchAppConfig = async (stage = 'production') => {
8
16
  const configUrl = `https://link-types-config.${stage}.linktr.ee/create-link-app.json`;
9
17
  try {
10
18
  const response = await axios_1.default.get(configUrl);
11
- return response.data;
19
+ const config = response.data;
20
+ const projectIdOverride = process.env[PROJECT_ID_ENV_VARS[stage]] ?? process.env.LINKAPP_DESCOPE_PROJECT_ID;
21
+ if (projectIdOverride) {
22
+ config.auth.project_id = projectIdOverride;
23
+ return config;
24
+ }
25
+ // Inject project_id if not present in remote config (temporary until Davis config is deployed)
26
+ if (!config.auth.project_id && config.auth.domain) {
27
+ config.auth.project_id = PROJECT_IDS[config.auth.domain];
28
+ }
29
+ return config;
12
30
  }
13
31
  catch (err) {
14
32
  if (axios_1.default.isAxiosError(err)) {
15
- throw new Error(err.message);
33
+ throw new Error(`Failed to fetch config from ${configUrl}: ${err.message}`);
16
34
  }
17
35
  throw err;
18
36
  }
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.writeLine = void 0;
4
+ function writeLine(message = '') {
5
+ process.stdout.write(message + '\n');
6
+ }
7
+ exports.writeLine = writeLine;
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.1",
2
+ "version": "2.2.2",
3
3
  "commands": {
4
4
  "build": {
5
5
  "id": "build",
@@ -233,7 +233,7 @@
233
233
  },
234
234
  "logout": {
235
235
  "id": "logout",
236
- "description": "Logout and clear browser session",
236
+ "description": "Logout and clear local credentials",
237
237
  "strict": true,
238
238
  "pluginName": "@linktr.ee/create-link-app",
239
239
  "pluginAlias": "@linktr.ee/create-link-app",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/create-link-app",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "Create a Link App on Linktr.ee.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Linktree",
@@ -33,6 +33,7 @@
33
33
  "@babel/preset-react": "^7.17.12",
34
34
  "@babel/preset-typescript": "^7.17.12",
35
35
  "@babel/runtime": "^7.18.3",
36
+ "@clack/prompts": "^0.7.0",
36
37
  "@linktr.ee/ui-link-kit": "latest",
37
38
  "@oclif/core": "^1.9.0",
38
39
  "@oclif/plugin-help": "5.1.23",
@@ -62,7 +63,9 @@
62
63
  "jsonwebtoken": "^8.5.1",
63
64
  "mini-css-extract-plugin": "^2.7.5",
64
65
  "netrc-parser": "^3.1.6",
66
+ "open": "^8.4.0",
65
67
  "openid-client": "^5.1.6",
68
+ "picocolors": "^1.0.0",
66
69
  "postcss": "^8.4.21",
67
70
  "postcss-loader": "^4.3.0",
68
71
  "prettier": "^2.8.8",