Merge branch 'v5/main' of github.com:strapi/strapi into fix/scheduling-update-error-message

This commit is contained in:
Fernando Chavez 2024-07-03 09:31:46 +02:00
commit 00056de9a2
346 changed files with 8314 additions and 2273 deletions

View File

@ -1,6 +1,6 @@
{
"name": "check-pr-status",
"version": "5.0.0-beta.12",
"version": "5.0.0-rc.1",
"private": true,
"license": "MIT",
"main": "dist/index.js",

2
.gitignore vendored
View File

@ -156,4 +156,4 @@ tests/cli/.env
# Strapi
############################
examples/**/types/generated
.nx/cache
.nx/cache

View File

@ -1,6 +1,6 @@
{
"name": "experimental-dev",
"version": "5.0.0-beta.12",
"version": "5.0.0-rc.1",
"private": true,
"description": "A Strapi application",
"license": "MIT",
@ -14,8 +14,8 @@
"strapi": "strapi"
},
"dependencies": {
"@strapi/plugin-users-permissions": "5.0.0-beta.12",
"@strapi/strapi": "5.0.0-beta.12",
"@strapi/plugin-users-permissions": "5.0.0-rc.1",
"@strapi/strapi": "5.0.0-rc.1",
"better-sqlite3": "9.4.3",
"react": "rc",
"react-dom": "rc",

View File

@ -1,6 +1,6 @@
{
"name": "getstarted",
"version": "5.0.0-beta.12",
"version": "5.0.0-rc.1",
"private": true,
"description": "A Strapi application.",
"license": "SEE LICENSE IN LICENSE",
@ -14,15 +14,15 @@
},
"dependencies": {
"@strapi/icons": "2.0.0-beta.6",
"@strapi/plugin-color-picker": "5.0.0-beta.12",
"@strapi/plugin-documentation": "5.0.0-beta.12",
"@strapi/plugin-graphql": "5.0.0-beta.12",
"@strapi/plugin-sentry": "5.0.0-beta.12",
"@strapi/plugin-users-permissions": "5.0.0-beta.12",
"@strapi/provider-email-mailgun": "5.0.0-beta.12",
"@strapi/provider-upload-aws-s3": "5.0.0-beta.12",
"@strapi/provider-upload-cloudinary": "5.0.0-beta.12",
"@strapi/strapi": "5.0.0-beta.12",
"@strapi/plugin-color-picker": "5.0.0-rc.1",
"@strapi/plugin-documentation": "5.0.0-rc.1",
"@strapi/plugin-graphql": "5.0.0-rc.1",
"@strapi/plugin-sentry": "5.0.0-rc.1",
"@strapi/plugin-users-permissions": "5.0.0-rc.1",
"@strapi/provider-email-mailgun": "5.0.0-rc.1",
"@strapi/provider-upload-aws-s3": "5.0.0-rc.1",
"@strapi/provider-upload-cloudinary": "5.0.0-rc.1",
"@strapi/strapi": "5.0.0-rc.1",
"better-sqlite3": "9.4.3",
"lodash": "4.17.21",
"mysql2": "3.9.4",

View File

@ -5,8 +5,6 @@ const config = {
locales: ['it', 'es', 'en'],
};
const bootstrap = (app) => {
console.log('I AM BOOTSTRAPPED');
app.getPlugin('content-manager').injectComponent('editView', 'right-links', {
name: 'PreviewButton',
Component: () => (

View File

@ -1,6 +1,6 @@
{
"name": "kitchensink-ts",
"version": "5.0.0-beta.12",
"version": "5.0.0-rc.1",
"private": true,
"description": "A Strapi application",
"license": "MIT",
@ -14,8 +14,8 @@
"strapi": "strapi"
},
"dependencies": {
"@strapi/plugin-users-permissions": "5.0.0-beta.12",
"@strapi/strapi": "5.0.0-beta.12",
"@strapi/plugin-users-permissions": "5.0.0-rc.1",
"@strapi/strapi": "5.0.0-rc.1",
"better-sqlite3": "9.4.3",
"react": "18.3.1",
"react-dom": "18.3.1",

View File

@ -1,6 +1,6 @@
{
"name": "kitchensink",
"version": "5.0.0-beta.12",
"version": "5.0.0-rc.1",
"private": true,
"description": "A Strapi application.",
"license": "SEE LICENSE IN LICENSE",
@ -13,10 +13,10 @@
"strapi": "strapi"
},
"dependencies": {
"@strapi/provider-email-mailgun": "5.0.0-beta.12",
"@strapi/provider-upload-aws-s3": "5.0.0-beta.12",
"@strapi/provider-upload-cloudinary": "5.0.0-beta.12",
"@strapi/strapi": "5.0.0-beta.12",
"@strapi/provider-email-mailgun": "5.0.0-rc.1",
"@strapi/provider-upload-aws-s3": "5.0.0-rc.1",
"@strapi/provider-upload-cloudinary": "5.0.0-rc.1",
"@strapi/strapi": "5.0.0-rc.1",
"better-sqlite3": "9.4.3",
"lodash": "4.17.21",
"mysql2": "3.9.4",

View File

@ -1,6 +1,6 @@
{
"name": "strapi-plugin-workspace-plugin",
"version": "5.0.0-beta.12",
"version": "5.0.0-rc.1",
"private": true,
"description": "This is the description of my plugin.",
"exports": {

View File

@ -1,4 +1,4 @@
{
"version": "5.0.0-beta.12",
"version": "5.0.0-rc.1",
"npmClient": "yarn"
}

View File

@ -50,7 +50,8 @@
"prettier:write": "prettier --write \"**/*.{js,ts,jsx,tsx,json,md,mdx,css,scss,yaml,yml}\"",
"setup": "yarn && yarn clean && yarn build --skip-nx-cache",
"test:api": "node tests/scripts/run-api-tests.js",
"test:clean": "rimraf ./coverage",
"test:api:clean": "rimraf ./coverage",
"test:clean": "yarn test:api:clean && yarn test:e2e:clean",
"test:cli": "node tests/scripts/run-cli-tests.js",
"test:cli:clean": "node tests/scripts/run-cli-tests.js clean",
"test:cli:debug": "node tests/scripts/run-cli-tests.js --debug",

View File

@ -2,7 +2,21 @@ Copyright (c) 2015-present Strapi Solutions SAS
Portions of the Strapi software are licensed as follows:
* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined in "ee/LICENSE".
* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined below.
Enterprise License
If you or the company you represent has entered into a written agreement referencing the Enterprise Edition of the Strapi source code available at
https://github.com/strapi/strapi, then such agreement applies to your use of the Enterprise Edition of the Strapi Software. If you or the company you
represent is using the Enterprise Edition of the Strapi Software in connection with a subscription to our cloud offering, then the agreement you have
agreed to with respect to our cloud offering and the licenses included in such agreement apply to your use of the Enterprise Edition of the Strapi Software.
Otherwise, the Strapi Enterprise Software License Agreement (found here https://strapi.io/enterprise-terms) applies to your use of the Enterprise Edition of the Strapi Software.
BY ACCESSING OR USING THE ENTERPRISE EDITION OF THE STRAPI SOFTWARE, YOU ARE AGREEING TO BE BOUND BY THE RELEVANT REFERENCED AGREEMENT.
IF YOU ARE NOT AUTHORIZED TO ACCEPT THESE TERMS ON BEHALF OF THE COMPANY YOU REPRESENT OR IF YOU DO NOT AGREE TO ALL OF THE RELEVANT TERMS AND CONDITIONS REFERENCED AND YOU
HAVE NOT OTHERWISE EXECUTED A WRITTEN AGREEMENT WITH STRAPI, YOU ARE NOT AUTHORIZED TO ACCESS OR USE OR ALLOW ANY USER TO ACCESS OR USE ANY PART OF
THE ENTERPRISE EDITION OF THE STRAPI SOFTWARE. YOUR ACCESS RIGHTS ARE CONDITIONAL ON YOUR CONSENT TO THE RELEVANT REFERENCED TERMS TO THE EXCLUSION OF ALL OTHER TERMS;
IF THE RELEVANT REFERENCED TERMS ARE CONSIDERED AN OFFER BY YOU, ACCEPTANCE IS EXPRESSLY LIMITED TO THE RELEVANT REFERENCED TERMS.
* All software outside of the above-mentioned directories or restrictions above is available under the "MIT Expat" license as set forth below.
@ -18,5 +32,6 @@ furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,6 +1,6 @@
{
"name": "@strapi/admin-test-utils",
"version": "5.0.0-beta.12",
"version": "5.0.0-rc.1",
"private": true,
"description": "Test utilities for the Strapi administration panel",
"license": "MIT",
@ -81,9 +81,9 @@
"@reduxjs/toolkit": "1.9.7",
"@strapi/pack-up": "5.0.0",
"@testing-library/jest-dom": "6.4.5",
"eslint-config-custom": "5.0.0-beta.12",
"eslint-config-custom": "5.0.0-rc.1",
"jest-environment-jsdom": "29.6.1",
"tsconfig": "5.0.0-beta.12"
"tsconfig": "5.0.0-rc.1"
},
"peerDependencies": {
"@reduxjs/toolkit": "^1.9.7",

View File

@ -0,0 +1,4 @@
node_modules/
.eslintrc.js
dist/
bin/

View File

@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ['custom/back/typescript'],
};

View File

@ -0,0 +1,37 @@
Copyright (c) 2015-present Strapi Solutions SAS
Portions of the Strapi software are licensed as follows:
* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined below.
Enterprise License
If you or the company you represent has entered into a written agreement referencing the Enterprise Edition of the Strapi source code available at
https://github.com/strapi/strapi, then such agreement applies to your use of the Enterprise Edition of the Strapi Software. If you or the company you
represent is using the Enterprise Edition of the Strapi Software in connection with a subscription to our cloud offering, then the agreement you have
agreed to with respect to our cloud offering and the licenses included in such agreement apply to your use of the Enterprise Edition of the Strapi Software.
Otherwise, the Strapi Enterprise Software License Agreement (found here https://strapi.io/enterprise-terms) applies to your use of the Enterprise Edition of the Strapi Software.
BY ACCESSING OR USING THE ENTERPRISE EDITION OF THE STRAPI SOFTWARE, YOU ARE AGREEING TO BE BOUND BY THE RELEVANT REFERENCED AGREEMENT.
IF YOU ARE NOT AUTHORIZED TO ACCEPT THESE TERMS ON BEHALF OF THE COMPANY YOU REPRESENT OR IF YOU DO NOT AGREE TO ALL OF THE RELEVANT TERMS AND CONDITIONS REFERENCED AND YOU
HAVE NOT OTHERWISE EXECUTED A WRITTEN AGREEMENT WITH STRAPI, YOU ARE NOT AUTHORIZED TO ACCESS OR USE OR ALLOW ANY USER TO ACCESS OR USE ANY PART OF
THE ENTERPRISE EDITION OF THE STRAPI SOFTWARE. YOUR ACCESS RIGHTS ARE CONDITIONAL ON YOUR CONSENT TO THE RELEVANT REFERENCED TERMS TO THE EXCLUSION OF ALL OTHER TERMS;
IF THE RELEVANT REFERENCED TERMS ARE CONSIDERED AN OFFER BY YOU, ACCEPTANCE IS EXPRESSLY LIMITED TO THE RELEVANT REFERENCED TERMS.
* All software outside of the above-mentioned directories or restrictions above is available under the "MIT Expat" license as set forth below.
MIT Expat License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,3 @@
# Cloud CLI
This package includes the `cloud` CLI to manage Strapi projects on the cloud.

View File

@ -0,0 +1,7 @@
#!/usr/bin/env node
'use strict';
const { runStrapiCloudCommand } = require('../dist/bin');
runStrapiCloudCommand(process.argv);

View File

@ -0,0 +1,79 @@
{
"name": "@strapi/cloud-cli",
"version": "5.0.0-rc.1",
"description": "Commands to interact with the Strapi Cloud",
"keywords": [
"strapi",
"cloud",
"cli"
],
"homepage": "https://strapi.io",
"bugs": {
"url": "https://github.com/strapi/strapi/issues"
},
"repository": {
"type": "git",
"url": "git://github.com/strapi/strapi.git"
},
"license": "SEE LICENSE IN LICENSE",
"author": {
"name": "Strapi Solutions SAS",
"email": "hi@strapi.io",
"url": "https://strapi.io"
},
"maintainers": [
{
"name": "Strapi Solutions SAS",
"email": "hi@strapi.io",
"url": "https://strapi.io"
}
],
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"source": "./src/index.ts",
"types": "./dist/src/index.d.ts",
"bin": "./bin/index.js",
"files": [
"./dist",
"./bin"
],
"scripts": {
"build": "pack-up build",
"clean": "run -T rimraf ./dist",
"lint": "run -T eslint .",
"watch": "pack-up watch"
},
"dependencies": {
"@strapi/utils": "5.0.0-rc.1",
"axios": "1.6.0",
"chalk": "4.1.2",
"cli-progress": "3.12.0",
"commander": "8.3.0",
"eventsource": "2.0.2",
"fast-safe-stringify": "2.1.1",
"fs-extra": "10.0.0",
"inquirer": "8.2.5",
"jsonwebtoken": "9.0.0",
"jwks-rsa": "3.1.0",
"lodash": "4.17.21",
"minimatch": "9.0.3",
"open": "8.4.0",
"ora": "5.4.1",
"pkg-up": "3.1.0",
"tar": "6.1.13",
"xdg-app-paths": "8.3.0",
"yup": "0.32.9"
},
"devDependencies": {
"@strapi/pack-up": "4.23.0",
"@types/cli-progress": "3.11.5",
"@types/eventsource": "1.1.15",
"@types/lodash": "^4.14.191",
"eslint-config-custom": "5.0.0-rc.1",
"tsconfig": "5.0.0-rc.1"
},
"engines": {
"node": ">=18.0.0 <=20.x.x",
"npm": ">=6.0.0"
}
}

View File

@ -0,0 +1,20 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { defineConfig } from '@strapi/pack-up';
export default defineConfig({
bundles: [
{
source: './src/index.ts',
import: './dist/index.js',
require: './dist/index.js',
types: './dist/index.d.ts',
runtime: 'node',
},
{
source: './src/bin.ts',
require: './dist/bin.js',
runtime: 'node',
},
],
dist: './dist',
});

View File

@ -0,0 +1,34 @@
import { Command } from 'commander';
import { createLogger } from './services';
import { CLIContext } from './types';
import { buildStrapiCloudCommands } from './index';
function loadStrapiCloudCommand(argv = process.argv, command = new Command()) {
// Initial program setup
command.storeOptionsAsProperties(false).allowUnknownOption(true);
// Help command
command.helpOption('-h, --help', 'Display help for command');
command.addHelpCommand('help [command]', 'Display help for command');
const cwd = process.cwd();
const hasDebug = argv.includes('--debug');
const hasSilent = argv.includes('--silent');
const logger = createLogger({ debug: hasDebug, silent: hasSilent, timestamp: false });
const ctx = {
cwd,
logger,
} satisfies CLIContext;
buildStrapiCloudCommands({ command, ctx, argv });
}
function runStrapiCloudCommand(argv = process.argv, command = new Command()) {
loadStrapiCloudCommand(argv, command);
command.parse(argv);
}
export { runStrapiCloudCommand };

View File

@ -0,0 +1,6 @@
import { env } from '@strapi/utils';
export const apiConfig = {
apiBaseUrl: env('STRAPI_CLI_CLOUD_API', 'https://cloud-cli-api.strapi.io'),
dashboardBaseUrl: env('STRAPI_CLI_CLOUD_DASHBOARD', 'https://cloud.strapi.io'),
};

View File

@ -0,0 +1,57 @@
import path from 'path';
import os from 'os';
import fse from 'fs-extra';
import XDGAppPaths from 'xdg-app-paths';
const APP_FOLDER_NAME = 'com.strapi.cli';
export const CONFIG_FILENAME = 'config.json';
export type LocalConfig = {
token?: string;
deviceId?: string;
};
async function checkDirectoryExists(directoryPath: string) {
try {
const fsStat = await fse.lstat(directoryPath);
return fsStat.isDirectory();
} catch (e) {
return false;
}
}
// Determine storage path based on the operating system
export async function getTmpStoragePath() {
const storagePath = path.join(os.tmpdir(), APP_FOLDER_NAME);
await fse.ensureDir(storagePath);
return storagePath;
}
async function getConfigPath() {
const configDirs = XDGAppPaths(APP_FOLDER_NAME).configDirs();
const configPath = configDirs.find(checkDirectoryExists);
if (!configPath) {
await fse.ensureDir(configDirs[0]);
return configDirs[0];
}
return configPath;
}
export async function getLocalConfig(): Promise<LocalConfig> {
const configPath = await getConfigPath();
const configFilePath = path.join(configPath, CONFIG_FILENAME);
await fse.ensureFile(configFilePath);
try {
return await fse.readJSON(configFilePath, { encoding: 'utf8', throws: true });
} catch (e) {
return {};
}
}
export async function saveLocalConfig(data: LocalConfig) {
const configPath = await getConfigPath();
const configFilePath = path.join(configPath, CONFIG_FILENAME);
await fse.writeJson(configFilePath, data, { encoding: 'utf8', spaces: 2, mode: 0o600 });
}

View File

@ -0,0 +1,73 @@
import inquirer from 'inquirer';
import { AxiosError } from 'axios';
import { defaults } from 'lodash/fp';
import type { CLIContext, ProjectAnswers, ProjectInput } from '../types';
import { tokenServiceFactory, cloudApiFactory, local } from '../services';
async function handleError(ctx: CLIContext, error: Error) {
const tokenService = await tokenServiceFactory(ctx);
const { logger } = ctx;
logger.debug(error);
if (error instanceof AxiosError) {
const errorMessage = typeof error.response?.data === 'string' ? error.response.data : null;
switch (error.response?.status) {
case 401:
logger.error('Your session has expired. Please log in again.');
await tokenService.eraseToken();
return;
case 403:
logger.error(
errorMessage ||
'You do not have permission to create a project. Please contact support for assistance.'
);
return;
case 400:
logger.error(errorMessage || 'Invalid input. Please check your inputs and try again.');
return;
case 503:
logger.error(
'Strapi Cloud project creation is currently unavailable. Please try again later.'
);
return;
default:
if (errorMessage) {
logger.error(errorMessage);
return;
}
break;
}
}
logger.error(
'We encountered an issue while creating your project. Please try again in a moment. If the problem persists, contact support for assistance.'
);
}
export default async (ctx: CLIContext) => {
const { logger } = ctx;
const { getValidToken } = await tokenServiceFactory(ctx);
const token = await getValidToken();
if (!token) {
return;
}
const cloudApi = await cloudApiFactory(token);
const { data: config } = await cloudApi.config();
const { questions, defaults: defaultValues } = config.projectCreation;
const projectAnswersDefaulted = defaults(defaultValues);
const projectAnswers = await inquirer.prompt<ProjectAnswers>(questions);
const projectInput: ProjectInput = projectAnswersDefaulted(projectAnswers);
const spinner = logger.spinner('Setting up your project...').start();
try {
const { data } = await cloudApi.createProject(projectInput);
await local.save({ project: data });
spinner.succeed('Project created successfully!');
return data;
} catch (e: Error | unknown) {
spinner.fail('Failed to create project on Strapi Cloud.');
await handleError(ctx, e as Error);
}
};

View File

@ -0,0 +1,17 @@
import { createCommand } from 'commander';
import { type StrapiCloudCommand } from '../types';
import { runAction } from '../utils/helpers';
import action from './action';
/**
* `$ create project in Strapi cloud`
*/
const command: StrapiCloudCommand = ({ ctx }) => {
return createCommand('cloud:create-project')
.description('Create a Strapi Cloud project')
.option('-d, --debug', 'Enable debugging mode with verbose logs')
.option('-s, --silent', "Don't log anything")
.action(() => runAction('cloud:create-project', action)(ctx));
};
export default command;

View File

@ -0,0 +1,12 @@
import action from './action';
import command from './command';
import type { StrapiCloudCommandInfo } from '../types';
export { action, command };
export default {
name: 'create-project',
description: 'Create a new project',
action,
command,
} as StrapiCloudCommandInfo;

View File

@ -0,0 +1,196 @@
import fse from 'fs-extra';
import path from 'path';
import chalk from 'chalk';
import { AxiosError } from 'axios';
import * as crypto from 'node:crypto';
import { apiConfig } from '../config/api';
import { compressFilesToTar } from '../utils/compress-files';
import createProjectAction from '../create-project/action';
import type { CLIContext, ProjectInfos } from '../types';
import { getTmpStoragePath } from '../config/local';
import { cloudApiFactory, tokenServiceFactory, local } from '../services';
import { notificationServiceFactory } from '../services/notification';
import { loadPkg } from '../utils/pkg';
import { buildLogsServiceFactory } from '../services/build-logs';
type PackageJson = {
name: string;
strapi?: {
uuid: string;
};
};
async function upload(
ctx: CLIContext,
project: ProjectInfos,
token: string,
maxProjectFileSize: number
) {
const cloudApi = await cloudApiFactory(token);
// * Upload project
try {
const storagePath = await getTmpStoragePath();
const projectFolder = path.resolve(process.cwd());
const packageJson = (await loadPkg(ctx)) as PackageJson;
if (!packageJson) {
ctx.logger.error(
'Unable to deploy the project. Please make sure the package.json file is correctly formatted.'
);
return;
}
ctx.logger.log('📦 Compressing project...');
// hash packageJson.name to avoid conflicts
const hashname = crypto.createHash('sha512').update(packageJson.name).digest('hex');
const compressedFilename = `${hashname}.tar.gz`;
try {
ctx.logger.debug(
'Compression parameters\n',
`Storage path: ${storagePath}\n`,
`Project folder: ${projectFolder}\n`,
`Compressed filename: ${compressedFilename}`
);
await compressFilesToTar(storagePath, projectFolder, compressedFilename);
ctx.logger.log('📦 Project compressed successfully!');
} catch (e: unknown) {
ctx.logger.error(
'⚠️ Project compression failed. Try again later or check for large/incompatible files.'
);
ctx.logger.debug(e);
process.exit(1);
}
const tarFilePath = path.resolve(storagePath, compressedFilename);
const fileStats = await fse.stat(tarFilePath);
if (fileStats.size > maxProjectFileSize) {
ctx.logger.log(
'Unable to proceed: Your project is too big to be transferred, please use a git repo instead.'
);
try {
await fse.remove(tarFilePath);
} catch (e: any) {
ctx.logger.log('Unable to remove file: ', tarFilePath);
ctx.logger.debug(e);
}
return;
}
ctx.logger.info('🚀 Uploading project...');
const progressBar = ctx.logger.progressBar(100, 'Upload Progress');
try {
const { data } = await cloudApi.deploy(
{ filePath: tarFilePath, project },
{
onUploadProgress(progressEvent) {
const total = progressEvent.total || fileStats.size;
const percentage = Math.round((progressEvent.loaded * 100) / total);
progressBar.update(percentage);
},
}
);
progressBar.update(100);
progressBar.stop();
ctx.logger.success('✨ Upload finished!');
return data.build_id;
} catch (e: any) {
progressBar.stop();
if (e instanceof AxiosError && e.response?.data) {
if (e.response.status === 404) {
ctx.logger.error(
`The project does not exist. Remove the ${local.LOCAL_SAVE_FILENAME} file and try again.`
);
} else {
ctx.logger.error(e.response.data);
}
} else {
ctx.logger.error('An error occurred while deploying the project. Please try again later.');
}
ctx.logger.debug(e);
} finally {
await fse.remove(tarFilePath);
}
process.exit(0);
} catch (e: any) {
ctx.logger.error('An error occurred while deploying the project. Please try again later.');
ctx.logger.debug(e);
process.exit(1);
}
}
async function getProject(ctx: CLIContext) {
const { project } = await local.retrieve();
if (!project) {
try {
return await createProjectAction(ctx);
} catch (e: any) {
ctx.logger.error('An error occurred while deploying the project. Please try again later.');
ctx.logger.debug(e);
process.exit(1);
}
}
return project;
}
export default async (ctx: CLIContext) => {
const { getValidToken } = await tokenServiceFactory(ctx);
const cloudApiService = await cloudApiFactory();
const token = await getValidToken();
if (!token) {
return;
}
const project = await getProject(ctx);
if (!project) {
return;
}
try {
await cloudApiService.track('willDeployWithCLI', { projectInternalName: project.name });
} catch (e) {
ctx.logger.debug('Failed to track willDeploy', e);
}
const notificationService = notificationServiceFactory(ctx);
const buildLogsService = buildLogsServiceFactory(ctx);
const { data: cliConfig } = await cloudApiService.config();
let maxSize: number = parseInt(cliConfig.maxProjectFileSize, 10);
if (Number.isNaN(maxSize)) {
ctx.logger.debug(
'An error occurred while parsing the maxProjectFileSize. Using default value.'
);
maxSize = 100000000;
}
const buildId = await upload(ctx, project, token, maxSize);
if (!buildId) {
return;
}
try {
notificationService(`${apiConfig.apiBaseUrl}/notifications`, token, cliConfig);
await buildLogsService(`${apiConfig.apiBaseUrl}/v1/logs/${buildId}`, token, cliConfig);
ctx.logger.log(
'Visit the following URL for deployment logs. Your deployment will be available here shortly.'
);
ctx.logger.log(
chalk.underline(`${apiConfig.dashboardBaseUrl}/projects/${project.name}/deployments`)
);
} catch (e: Error | unknown) {
if (e instanceof Error) {
ctx.logger.error(e.message);
} else {
throw e;
}
}
};

View File

@ -0,0 +1,18 @@
import { createCommand } from 'commander';
import { type StrapiCloudCommand } from '../types';
import { runAction } from '../utils/helpers';
import action from './action';
/**
* `$ deploy project to the cloud`
*/
const command: StrapiCloudCommand = ({ ctx }) => {
return createCommand('cloud:deploy')
.alias('deploy')
.description('Deploy a Strapi Cloud project')
.option('-d, --debug', 'Enable debugging mode with verbose logs')
.option('-s, --silent', "Don't log anything")
.action(() => runAction('deploy', action)(ctx));
};
export default command;

View File

@ -0,0 +1,12 @@
import action from './action';
import command from './command';
import type { StrapiCloudCommandInfo } from '../types';
export { action, command };
export default {
name: 'deploy-project',
description: 'Deploy a Strapi Cloud project',
action,
command,
} as StrapiCloudCommandInfo;

View File

@ -0,0 +1,56 @@
import { Command } from 'commander';
import crypto from 'crypto';
import deployProject from './deploy-project';
import login from './login';
import logout from './logout';
import createProject from './create-project';
import { CLIContext } from './types';
import { getLocalConfig, saveLocalConfig } from './config/local';
export const cli = {
deployProject,
login,
logout,
createProject,
};
const cloudCommands = [deployProject, login, logout];
async function initCloudCLIConfig() {
const localConfig = await getLocalConfig();
if (!localConfig.deviceId) {
localConfig.deviceId = crypto.randomUUID();
}
await saveLocalConfig(localConfig);
}
export async function buildStrapiCloudCommands({
command,
ctx,
argv,
}: {
command: Command;
ctx: CLIContext;
argv: string[];
}) {
await initCloudCLIConfig();
// Load all commands
for (const cloudCommand of cloudCommands) {
try {
// Add this command to the Commander command object
const subCommand = await cloudCommand.command({ command, ctx, argv });
if (subCommand) {
command.addCommand(subCommand);
}
} catch (e) {
console.error(`Failed to load command ${cloudCommand.name}`, e);
}
}
}
export * as services from './services';
export * from './types';

View File

@ -0,0 +1,187 @@
import axios, { AxiosResponse, AxiosError } from 'axios';
import chalk from 'chalk';
import { tokenServiceFactory, cloudApiFactory } from '../services';
import type { CloudCliConfig, CLIContext } from '../types';
import { apiConfig } from '../config/api';
const openModule = import('open');
export default async (ctx: CLIContext): Promise<boolean> => {
const { logger } = ctx;
const tokenService = await tokenServiceFactory(ctx);
const existingToken = await tokenService.retrieveToken();
const cloudApiService = await cloudApiFactory(existingToken || undefined);
const trackFailedLogin = async () => {
try {
await cloudApiService.track('didNotLogin', { loginMethod: 'cli' });
} catch (e) {
logger.debug('Failed to track failed login', e);
}
};
if (existingToken) {
const isTokenValid = await tokenService.isTokenValid(existingToken);
if (isTokenValid) {
try {
const userInfo = await cloudApiService.getUserInfo();
const { email } = userInfo.data.data;
if (email) {
logger.log(`You are already logged into your account (${email}).`);
} else {
logger.log('You are already logged in.');
}
logger.log(
'To access your dashboard, please copy and paste the following URL into your web browser:'
);
logger.log(chalk.underline(`${apiConfig.dashboardBaseUrl}/projects`));
return true;
} catch (e) {
logger.debug('Failed to fetch user info', e);
// If the token is invalid and request failed, we should proceed with the login process
}
}
}
let cliConfig: CloudCliConfig;
try {
logger.info('🔌 Connecting to the Strapi Cloud API...');
const config = await cloudApiService.config();
cliConfig = config.data;
} catch (e: unknown) {
logger.error('🥲 Oops! Something went wrong while logging you in. Please try again.');
logger.debug(e);
return false;
}
try {
await cloudApiService.track('willLoginAttempt', {});
} catch (e) {
logger.debug('Failed to track login attempt', e);
}
logger.debug('🔐 Creating device authentication request...', {
client_id: cliConfig.clientId,
scope: cliConfig.scope,
audience: cliConfig.audience,
});
const deviceAuthResponse = (await axios
.post(cliConfig.deviceCodeAuthUrl, {
client_id: cliConfig.clientId,
scope: cliConfig.scope,
audience: cliConfig.audience,
})
.catch((e: AxiosError) => {
logger.error('There was an issue with the authentication process. Please try again.');
if (e.message) {
logger.debug(e.message, e);
} else {
logger.debug(e);
}
})) as AxiosResponse;
openModule.then((open) => {
open.default(deviceAuthResponse.data.verification_uri_complete).catch((e: Error) => {
logger.error('We encountered an issue opening the browser. Please try again later.');
logger.debug(e.message, e);
});
});
logger.log('If a browser tab does not open automatically, please follow the next steps:');
logger.log(
`1. Open this url in your device: ${deviceAuthResponse.data.verification_uri_complete}`
);
logger.log(
`2. Enter the following code: ${deviceAuthResponse.data.user_code} and confirm to login.\n`
);
const tokenPayload = {
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: deviceAuthResponse.data.device_code,
client_id: cliConfig.clientId,
};
let isAuthenticated = false;
const authenticate = async () => {
const spinner = logger.spinner('Waiting for authentication');
spinner.start();
const spinnerFail = () => spinner.fail('Authentication failed!');
while (!isAuthenticated) {
try {
const tokenResponse = await axios.post(cliConfig.tokenUrl, tokenPayload);
const authTokenData = tokenResponse.data;
if (tokenResponse.status === 200) {
// Token validation
try {
logger.debug('🔐 Validating token...');
await tokenService.validateToken(authTokenData.id_token, cliConfig.jwksUrl);
logger.debug('🔐 Token validation successful!');
} catch (e: any) {
logger.debug(e);
spinnerFail();
throw new Error('Unable to proceed: Token validation failed');
}
logger.debug('🔍 Fetching user information...');
const cloudApiServiceWithToken = await cloudApiFactory(authTokenData.access_token);
// Call to get user info to create the user in DB if not exists
await cloudApiServiceWithToken.getUserInfo();
logger.debug('🔍 User information fetched successfully!');
try {
logger.debug('📝 Saving login information...');
await tokenService.saveToken(authTokenData.access_token);
logger.debug('📝 Login information saved successfully!');
isAuthenticated = true;
} catch (e) {
logger.error(
'There was a problem saving your login information. Please try logging in again.'
);
logger.debug(e);
spinnerFail();
return false;
}
}
} catch (e: any) {
if (e.message === 'Unable to proceed: Token validation failed') {
logger.error(
'There seems to be a problem with your login information. Please try logging in again.'
);
spinnerFail();
await trackFailedLogin();
return false;
}
if (
e.response?.data.error &&
!['authorization_pending', 'slow_down'].includes(e!.response.data.error)
) {
logger.debug(e);
spinnerFail();
await trackFailedLogin();
return false;
}
// Await interval before retrying
await new Promise((resolve) => {
setTimeout(resolve, deviceAuthResponse.data.interval * 1000);
});
}
}
spinner.succeed('Authentication successful!');
logger.log('You are now logged into Strapi Cloud.');
logger.log(
'To access your dashboard, please copy and paste the following URL into your web browser:'
);
logger.log(chalk.underline(`${apiConfig.dashboardBaseUrl}/projects`));
try {
await cloudApiService.track('didLogin', { loginMethod: 'cli' });
} catch (e) {
logger.debug('Failed to track login', e);
}
};
await authenticate();
return isAuthenticated;
};

View File

@ -0,0 +1,22 @@
import { createCommand } from 'commander';
import type { StrapiCloudCommand } from '../types';
import { runAction } from '../utils/helpers';
import action from './action';
/**
* `$ cloud device flow login`
*/
const command: StrapiCloudCommand = ({ ctx }) => {
return createCommand('cloud:login')
.alias('login')
.description('Strapi Cloud Login')
.addHelpText(
'after',
'\nAfter running this command, you will be prompted to enter your authentication information.'
)
.option('-d, --debug', 'Enable debugging mode with verbose logs')
.option('-s, --silent', "Don't log anything")
.action(() => runAction('login', action)(ctx));
};
export default command;

View File

@ -0,0 +1,12 @@
import action from './action';
import command from './command';
import type { StrapiCloudCommandInfo } from '../types';
export { action, command };
export default {
name: 'login',
description: 'Strapi Cloud Login',
action,
command,
} as StrapiCloudCommandInfo;

View File

@ -0,0 +1,29 @@
import type { CLIContext } from '../types';
import { tokenServiceFactory, cloudApiFactory } from '../services';
export default async (ctx: CLIContext) => {
const { logger } = ctx;
const { retrieveToken, eraseToken } = await tokenServiceFactory(ctx);
const token = await retrieveToken();
if (!token) {
logger.log("You're already logged out.");
return;
}
const cloudApiService = await cloudApiFactory(token);
try {
// we might want also to perform extra actions like logging out from the auth0 tenant
await eraseToken();
logger.log(
'🔌 You have been logged out from the CLI. If you are on a shared computer, please make sure to log out from the Strapi Cloud Dashboard as well.'
);
} catch (e) {
logger.error('🥲 Oops! Something went wrong while logging you out. Please try again.');
logger.debug(e);
}
try {
await cloudApiService.track('didLogout', { loginMethod: 'cli' });
} catch (e) {
logger.debug('Failed to track logout event', e);
}
};

View File

@ -0,0 +1,18 @@
import { createCommand } from 'commander';
import type { StrapiCloudCommand } from '../types';
import { runAction } from '../utils/helpers';
import action from './action';
/**
* `$ cloud device flow logout`
*/
const command: StrapiCloudCommand = ({ ctx }) => {
return createCommand('cloud:logout')
.alias('logout')
.description('Strapi Cloud Logout')
.option('-d, --debug', 'Enable debugging mode with verbose logs')
.option('-s, --silent', "Don't log anything")
.action(() => runAction('logout', action)(ctx));
};
export default command;

View File

@ -0,0 +1,11 @@
import action from './action';
import command from './command';
export { action, command };
export default {
name: 'logout',
description: 'Strapi Cloud Logout',
action,
command,
};

View File

@ -0,0 +1,75 @@
import EventSource from 'eventsource';
import { CLIContext, type CloudCliConfig } from '../types';
const buildLogsServiceFactory = ({ logger }: CLIContext) => {
return async (url: string, token: string, cliConfig: CloudCliConfig) => {
const CONN_TIMEOUT = Number(cliConfig.buildLogsConnectionTimeout);
const MAX_RETRIES = Number(cliConfig.buildLogsMaxRetries);
return new Promise((resolve, reject) => {
let timeoutId: NodeJS.Timeout | null = null;
let retries = 0;
const connect = (url: string) => {
const spinner = logger.spinner('Connecting to server to get build logs');
spinner.start();
const es = new EventSource(`${url}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const clearExistingTimeout = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
const resetTimeout = () => {
clearExistingTimeout();
timeoutId = setTimeout(() => {
if (spinner.isSpinning) {
spinner.fail(
'We were unable to connect to the server to get build logs at this time. This could be due to a temporary issue.'
);
}
es.close();
reject(new Error('Connection timed out'));
}, CONN_TIMEOUT);
};
es.onopen = resetTimeout;
es.addEventListener('finished', (event) => {
const data = JSON.parse(event.data);
logger.log(data.msg);
es.close();
clearExistingTimeout();
resolve(null);
});
es.addEventListener('log', (event) => {
if (spinner.isSpinning) {
spinner.succeed();
}
resetTimeout();
const data = JSON.parse(event.data);
logger.log(data.msg);
});
es.onerror = async () => {
retries += 1;
if (retries > MAX_RETRIES) {
spinner.fail('We were unable to connect to the server to get build logs at this time.');
es.close();
reject(new Error('Max retries reached'));
}
};
};
connect(url);
});
};
};
export { buildLogsServiceFactory };

View File

@ -0,0 +1,129 @@
import axios, { type AxiosResponse } from 'axios';
import fse from 'fs-extra';
import os from 'os';
import { apiConfig } from '../config/api';
import type { CloudCliConfig } from '../types';
import { getLocalConfig } from '../config/local';
import packageJson from '../../package.json';
export const VERSION = 'v1';
export type ProjectInfos = {
name: string;
nodeVersion: string;
region: string;
plan?: string;
url?: string;
};
export type ProjectInput = Omit<ProjectInfos, 'id'>;
export type DeployResponse = {
build_id: string;
image: string;
};
export type TrackPayload = Record<string, unknown>;
export interface CloudApiService {
deploy(
deployInput: {
filePath: string;
project: { name: string };
},
{
onUploadProgress,
}: {
onUploadProgress: (progressEvent: { loaded: number; total?: number }) => void;
}
): Promise<AxiosResponse<DeployResponse>>;
createProject(projectInput: ProjectInput): Promise<{
data: ProjectInfos;
status: number;
}>;
getUserInfo(): Promise<AxiosResponse>;
config(): Promise<AxiosResponse<CloudCliConfig>>;
listProjects(): Promise<AxiosResponse<ProjectInfos[]>>;
track(event: string, payload?: TrackPayload): Promise<AxiosResponse<void>>;
}
export async function cloudApiFactory(token?: string): Promise<CloudApiService> {
const localConfig = await getLocalConfig();
const customHeaders = {
'x-device-id': localConfig.deviceId,
'x-app-version': packageJson.version,
'x-os-name': os.type(),
'x-os-version': os.version(),
'x-language': Intl.DateTimeFormat().resolvedOptions().locale,
'x-node-version': process.versions.node,
};
const axiosCloudAPI = axios.create({
baseURL: `${apiConfig.apiBaseUrl}/${VERSION}`,
headers: {
'Content-Type': 'application/json',
...customHeaders,
},
});
if (token) {
axiosCloudAPI.defaults.headers.Authorization = `Bearer ${token}`;
}
return {
deploy({ filePath, project }, { onUploadProgress }) {
return axiosCloudAPI.post(
`/deploy/${project.name}`,
{ file: fse.createReadStream(filePath) },
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress,
}
);
},
async createProject({ name, nodeVersion, region, plan }) {
const response = await axiosCloudAPI.post('/project', {
projectName: name,
region,
nodeVersion,
plan,
});
return {
data: {
id: response.data.id,
name: response.data.name,
nodeVersion: response.data.nodeVersion,
region: response.data.region,
},
status: response.status,
};
},
getUserInfo() {
return axiosCloudAPI.get('/user');
},
config(): Promise<AxiosResponse<CloudCliConfig>> {
return axiosCloudAPI.get('/config');
},
listProjects() {
return axiosCloudAPI.get<ProjectInfos[]>('/projects');
},
track(event, payload = {}) {
return axiosCloudAPI.post<void>('/track', {
event,
payload,
});
},
};
}

View File

@ -0,0 +1,4 @@
export { cloudApiFactory } from './cli-api';
export * as local from './strapi-info-save';
export { tokenServiceFactory } from './token';
export { createLogger } from './logger';

View File

@ -0,0 +1,168 @@
import chalk from 'chalk';
import stringify from 'fast-safe-stringify';
import ora from 'ora';
import * as cliProgress from 'cli-progress';
export interface LoggerOptions {
silent?: boolean;
debug?: boolean;
timestamp?: boolean;
}
export interface Logger {
warnings: number;
errors: number;
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
success: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
log: (...args: unknown[]) => void;
spinner: (text: string) => Pick<ora.Ora, 'succeed' | 'fail' | 'start' | 'text' | 'isSpinning'>;
progressBar: (
totalSize: number,
text: string
) => Pick<cliProgress.SingleBar, 'start' | 'stop' | 'update'>;
}
const stringifyArg = (arg: unknown) => {
return typeof arg === 'object' ? stringify(arg) : arg;
};
const createLogger = (options: LoggerOptions = {}): Logger => {
const { silent = false, debug = false, timestamp = true } = options;
const state = { errors: 0, warning: 0 };
return {
get warnings() {
return state.warning;
},
get errors() {
return state.errors;
},
async debug(...args) {
if (silent || !debug) {
return;
}
console.log(
chalk.cyan(`[DEBUG]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`),
...args.map(stringifyArg)
);
},
info(...args) {
if (silent) {
return;
}
console.info(
chalk.blue(`[INFO]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`),
...args.map(stringifyArg)
);
},
log(...args) {
if (silent) {
return;
}
console.info(
chalk.blue(`${timestamp ? `\t[${new Date().toISOString()}]` : ''}`),
...args.map(stringifyArg)
);
},
success(...args) {
if (silent) {
return;
}
console.info(
chalk.green(`[SUCCESS]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`),
...args.map(stringifyArg)
);
},
warn(...args) {
state.warning += 1;
if (silent) {
return;
}
console.warn(
chalk.yellow(`[WARN]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`),
...args.map(stringifyArg)
);
},
error(...args) {
state.errors += 1;
if (silent) {
return;
}
console.error(
chalk.red(`[ERROR]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`),
...args.map(stringifyArg)
);
},
// @ts-expect-error returning a subpart of ora is fine because the types tell us what is what.
spinner(text: string) {
if (silent) {
return {
succeed() {
return this;
},
fail() {
return this;
},
start() {
return this;
},
text: '',
isSpinning: false,
};
}
return ora(text);
},
progressBar(totalSize: number, text: string) {
if (silent) {
return {
start() {
return this;
},
stop() {
return this;
},
update() {
return this;
},
};
}
const progressBar = new cliProgress.SingleBar({
format: `${text ? `${text} |` : ''}${chalk.green('{bar}')}| {percentage}%`,
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
hideCursor: true,
forceRedraw: true,
});
progressBar.start(totalSize, 0);
return progressBar;
},
};
};
export { createLogger };

View File

@ -0,0 +1,47 @@
import EventSource from 'eventsource';
import type { CLIContext, CloudCliConfig } from '../types';
type Event = {
type: string;
data: string;
lastEventId: string;
origin: string;
};
export function notificationServiceFactory({ logger }: CLIContext) {
return (url: string, token: string, cliConfig: CloudCliConfig) => {
const CONN_TIMEOUT = Number(cliConfig.notificationsConnectionTimeout);
const es = new EventSource(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
let timeoutId: NodeJS.Timeout;
const resetTimeout = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
logger.log(
'We were unable to connect to the server at this time. This could be due to a temporary issue. Please try again in a moment.'
);
es.close();
}, CONN_TIMEOUT); // 5 minutes
};
es.onopen = resetTimeout;
es.onmessage = (event: Event) => {
resetTimeout();
const data = JSON.parse(event.data);
if (data.message) {
logger.log(data.message);
}
// Close connection when a specific event is received
if (data.event === 'deploymentFinished' || data.event === 'deploymentFailed') {
es.close();
}
};
};
}

View File

@ -0,0 +1,30 @@
import fse from 'fs-extra';
import path from 'path';
import type { ProjectInfos } from './cli-api';
export const LOCAL_SAVE_FILENAME = '.strapi-cloud.json';
export type LocalSave = {
project?: ProjectInfos;
};
export async function save(data: LocalSave, { directoryPath }: { directoryPath?: string } = {}) {
const alreadyInFileData = await retrieve({ directoryPath });
const storedData = { ...alreadyInFileData, ...data };
const pathToFile = path.join(directoryPath || process.cwd(), LOCAL_SAVE_FILENAME);
// Ensure the directory exists
await fse.ensureDir(path.dirname(pathToFile));
await fse.writeJson(pathToFile, storedData, { encoding: 'utf8' });
}
export async function retrieve({
directoryPath,
}: { directoryPath?: string } = {}): Promise<LocalSave> {
const pathToFile = path.join(directoryPath || process.cwd(), LOCAL_SAVE_FILENAME);
const pathExists = await fse.pathExists(pathToFile);
if (!pathExists) {
return {};
}
return fse.readJSON(pathToFile, { encoding: 'utf8' });
}

View File

@ -0,0 +1,146 @@
import jwksClient, { type JwksClient, type SigningKey } from 'jwks-rsa';
import type { JwtHeader, VerifyErrors } from 'jsonwebtoken';
import jwt from 'jsonwebtoken';
import { getLocalConfig, saveLocalConfig } from '../config/local';
import type { CloudCliConfig, CLIContext } from '../types';
import { cloudApiFactory } from './cli-api';
let cliConfig: CloudCliConfig;
interface DecodedToken {
[key: string]: any;
}
export async function tokenServiceFactory({ logger }: { logger: CLIContext['logger'] }) {
const cloudApiService = await cloudApiFactory();
async function saveToken(str: string) {
const appConfig = await getLocalConfig();
if (!appConfig) {
logger.error('There was a problem saving your token. Please try again.');
return;
}
appConfig.token = str;
try {
await saveLocalConfig(appConfig);
} catch (e: Error | unknown) {
logger.debug(e);
logger.error('There was a problem saving your token. Please try again.');
}
}
async function retrieveToken() {
const appConfig = await getLocalConfig();
if (appConfig.token) {
// check if token is still valid
if (await isTokenValid(appConfig.token)) {
return appConfig.token;
}
}
return undefined;
}
async function validateToken(idToken: string, jwksUrl: string): Promise<void> {
const client: JwksClient = jwksClient({
jwksUri: jwksUrl,
});
// Get the Key from the JWKS using the token header's Key ID (kid)
const getKey = (header: JwtHeader, callback: (e: Error | null, key?: string) => void) => {
client.getSigningKey(header.kid, (e: Error | null, key?: SigningKey) => {
if (e) {
callback(e);
} else if (key) {
const publicKey = 'publicKey' in key ? key.publicKey : key.rsaPublicKey;
callback(null, publicKey);
} else {
callback(new Error('Key not found'));
}
});
};
// Decode the JWT token to get the header and payload
const decodedToken = jwt.decode(idToken, { complete: true }) as DecodedToken;
if (!decodedToken) {
if (typeof idToken === 'undefined' || idToken === '') {
logger.warn('You need to be logged in to use this feature. Please log in and try again.');
} else {
logger.error(
'There seems to be a problem with your login information. Please try logging in again.'
);
}
}
// Verify the JWT token signature using the JWKS Key
return new Promise<void>((resolve, reject) => {
jwt.verify(idToken, getKey, (err: VerifyErrors | null) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
async function isTokenValid(token: string) {
try {
const config = await cloudApiService.config();
cliConfig = config.data;
if (token) {
await validateToken(token, cliConfig.jwksUrl);
return true;
}
return false;
} catch (e) {
logger.debug(e);
return false;
}
}
async function eraseToken() {
const appConfig = await getLocalConfig();
if (!appConfig) {
return;
}
delete appConfig.token;
try {
await saveLocalConfig(appConfig);
} catch (e: Error | unknown) {
logger.debug(e);
logger.error(
'There was an issue removing your login information. Please try logging out again.'
);
throw e;
}
}
async function getValidToken() {
const token = await retrieveToken();
if (!token) {
logger.log('No token found. Please login first.');
return null;
}
if (!(await isTokenValid(token))) {
logger.log('Unable to proceed: Token is expired or not valid. Please login again.');
return null;
}
return token;
}
return {
saveToken,
retrieveToken,
validateToken,
isTokenValid,
eraseToken,
getValidToken,
};
}

View File

@ -0,0 +1,49 @@
import type { Command } from 'commander';
import type { DistinctQuestion } from 'inquirer';
import { Logger } from './services/logger';
export type ProjectAnswers = {
name: string;
nodeVersion: string;
region: string;
plan: string;
};
export type CloudCliConfig = {
clientId: string;
baseUrl: string;
deviceCodeAuthUrl: string;
audience: string;
scope: string;
tokenUrl: string;
jwksUrl: string;
projectCreation: {
questions: ReadonlyArray<DistinctQuestion<ProjectAnswers>>;
defaults: Partial<ProjectAnswers>;
introText: string;
};
buildLogsConnectionTimeout: string;
buildLogsMaxRetries: string;
notificationsConnectionTimeout: string;
maxProjectFileSize: string;
};
export interface CLIContext {
cwd: string;
logger: Logger;
}
export type StrapiCloudCommand = (params: {
command: Command;
argv: string[];
ctx: CLIContext;
}) => void | Command | Promise<Command | void>;
export type StrapiCloudCommandInfo = {
name: string;
description: string;
command: StrapiCloudCommand;
action: (ctx: CLIContext) => Promise<unknown>;
};
export type * from './services/cli-api';

View File

@ -0,0 +1,89 @@
// TODO Migrate to fs-extra
import * as fs from 'fs';
import * as tar from 'tar';
import * as path from 'path';
import { minimatch } from 'minimatch';
const IGNORED_PATTERNS = [
'**/.git/**',
'**/node_modules/**',
'**/build/**',
'**/dist/**',
'**/.cache/**',
'**/.circleci/**',
'**/.github/**',
'**/.gitignore',
'**/.gitkeep',
'**/.gitlab-ci.yml',
'**/.idea/**',
'**/.vscode/**',
];
const getFiles = (
dirPath: string,
ignorePatterns: string[] = [],
arrayOfFiles: string[] = [],
subfolder: string = ''
): string[] => {
const entries = fs.readdirSync(path.join(dirPath, subfolder));
entries.forEach((entry) => {
const entryPathFromRoot = path.join(subfolder, entry);
const entryPath = path.relative(dirPath, entryPathFromRoot);
const isIgnored = isIgnoredFile(dirPath, entryPathFromRoot, ignorePatterns);
if (isIgnored) {
return;
}
if (fs.statSync(entryPath).isDirectory()) {
getFiles(dirPath, ignorePatterns, arrayOfFiles, entryPathFromRoot);
} else {
arrayOfFiles.push(entryPath);
}
});
return arrayOfFiles;
};
const isIgnoredFile = (folderPath: string, file: string, ignorePatterns: string[]): boolean => {
ignorePatterns.push(...IGNORED_PATTERNS);
const relativeFilePath = path.join(folderPath, file);
let isIgnored = false;
for (const pattern of ignorePatterns) {
if (pattern.startsWith('!')) {
if (minimatch(relativeFilePath, pattern.slice(1), { matchBase: true, dot: true })) {
return false;
}
} else if (minimatch(relativeFilePath, pattern, { matchBase: true, dot: true })) {
if (path.basename(file) !== '.gitkeep') {
isIgnored = true;
}
}
}
return isIgnored;
};
const readGitignore = (folderPath: string): string[] => {
const gitignorePath = path.resolve(folderPath, '.gitignore');
if (!fs.existsSync(gitignorePath)) return [];
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
return gitignoreContent
.split(/\r?\n/)
.filter((line) => Boolean(line.trim()) && !line.startsWith('#'));
};
const compressFilesToTar = async (
storagePath: string,
folderToCompress: string,
filename: string
): Promise<void> => {
const ignorePatterns = readGitignore(folderToCompress);
const filesToCompress = getFiles(folderToCompress, ignorePatterns);
return tar.c(
{
gzip: true,
file: path.resolve(storagePath, filename),
},
filesToCompress
);
};
export { compressFilesToTar, isIgnoredFile };

View File

@ -0,0 +1,45 @@
import chalk from 'chalk';
import { has } from 'lodash/fp';
// TODO: Remove duplicated code by extracting to a shared package
const assertCwdContainsStrapiProject = (name: string) => {
const logErrorAndExit = () => {
console.log(
`You need to run ${chalk.yellow(
`strapi ${name}`
)} in a Strapi project. Make sure you are in the right directory.`
);
process.exit(1);
};
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkgJSON = require(`${process.cwd()}/package.json`);
if (
!has('dependencies.@strapi/strapi', pkgJSON) &&
!has('devDependencies.@strapi/strapi', pkgJSON)
) {
logErrorAndExit();
}
} catch (err) {
logErrorAndExit();
}
};
const runAction =
(name: string, action: (...args: any[]) => Promise<unknown>) =>
(...args: unknown[]) => {
assertCwdContainsStrapiProject(name);
Promise.resolve()
.then(() => {
return action(...args);
})
.catch((error) => {
console.error(error);
process.exit(1);
});
};
export { runAction };

View File

@ -0,0 +1,131 @@
// TODO Migrate to fs-extra
import fs from 'fs/promises';
import os from 'os';
import pkgUp from 'pkg-up';
import * as yup from 'yup';
import chalk from 'chalk';
import { Logger } from '../services/logger';
interface Export {
types?: string;
source: string;
module?: string;
import?: string;
require?: string;
default: string;
}
const packageJsonSchema = yup.object({
name: yup.string().required(),
exports: yup.lazy((value) =>
yup
.object(
typeof value === 'object'
? Object.entries(value).reduce(
(acc, [key, value]) => {
if (typeof value === 'object') {
acc[key] = yup
.object({
types: yup.string().optional(),
source: yup.string().required(),
module: yup.string().optional(),
import: yup.string().required(),
require: yup.string().required(),
default: yup.string().required(),
})
.noUnknown(true);
} else {
acc[key] = yup
.string()
.matches(/^\.\/.*\.json$/)
.required();
}
return acc;
},
{} as Record<string, yup.SchemaOf<string> | yup.SchemaOf<Export>>
)
: undefined
)
.optional()
),
});
type PackageJson = yup.Asserts<typeof packageJsonSchema>;
/**
* @description being a task to load the package.json starting from the current working directory
* using a shallow find for the package.json and `fs` to read the file. If no package.json is found,
* the process will throw with an appropriate error message.
*/
const loadPkg = async ({ cwd, logger }: { cwd: string; logger: Logger }): Promise<PackageJson> => {
const pkgPath = await pkgUp({ cwd });
if (!pkgPath) {
throw new Error('Could not find a package.json in the current directory');
}
const buffer = await fs.readFile(pkgPath);
const pkg = JSON.parse(buffer.toString());
logger.debug('Loaded package.json:', os.EOL, pkg);
return pkg;
};
/**
* @description validate the package.json against a standardised schema using `yup`.
* If the validation fails, the process will throw with an appropriate error message.
*/
const validatePkg = async ({ pkg }: { pkg: object }): Promise<PackageJson> => {
try {
const validatedPkg = await packageJsonSchema.validate(pkg, {
strict: true,
});
return validatedPkg;
} catch (err) {
if (err instanceof yup.ValidationError) {
switch (err.type) {
case 'required':
if (err.path) {
throw new Error(
`'${err.path}' in 'package.json' is required as type '${chalk.magenta(
yup.reach(packageJsonSchema, err.path).type
)}'`
);
}
break;
/**
* This will only be thrown if there are keys in the export map
* that we don't expect so we can therefore make some assumptions
*/
case 'noUnknown':
if (err.path && err.params && 'unknown' in err.params) {
throw new Error(
`'${err.path}' in 'package.json' contains the unknown key ${chalk.magenta(
err.params.unknown
)}, for compatability only the following keys are allowed: ${chalk.magenta(
"['types', 'source', 'import', 'require', 'default']"
)}`
);
}
break;
default:
if (err.path && err.params && 'type' in err.params && 'value' in err.params) {
throw new Error(
`'${err.path}' in 'package.json' must be of type '${chalk.magenta(
err.params.type
)}' (recieved '${chalk.magenta(typeof err.params.value)}')`
);
}
}
}
throw err;
}
};
export type { PackageJson, Export };
export { loadPkg, validatePkg };

View File

@ -0,0 +1,37 @@
import path from 'path';
import os from 'os';
import { isIgnoredFile } from '../compress-files';
describe('isIgnoredFile', () => {
const folderPath = os.tmpdir(); // We are using the system's directory path for simulating a real path
it('should correctly handle various ignore patterns', () => {
const allFiles = [
path.join(folderPath, 'file1.txt'),
path.join(folderPath, 'file2.txt'),
path.join(folderPath, 'node_modules', 'file3.js'),
path.join(folderPath, '.git', 'file4.js'),
path.join(folderPath, 'dist', 'file5.js'),
path.join(folderPath, 'public', 'uploads', '.gitkeep'),
path.join(folderPath, 'src', 'secret', 'file6.js'),
path.join(folderPath, 'src', 'secret', 'keep.me'),
path.join(folderPath, 'test', 'file7.test.ts'),
];
const ignorePatterns = [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'!public/uploads/.gitkeep',
'!**/*.test.ts',
'**/src/secret/**',
'!**/src/secret/keep.me',
];
const result = allFiles.filter((file) => !isIgnoredFile(folderPath, file, ignorePatterns));
expect(result).toEqual([
path.join(folderPath, 'file1.txt'),
path.join(folderPath, 'file2.txt'),
path.join(folderPath, 'public', 'uploads', '.gitkeep'),
path.join(folderPath, 'src', 'secret', 'keep.me'),
path.join(folderPath, 'test', 'file7.test.ts'),
]);
});
});

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules", "**/__tests__/**"]
}

View File

@ -0,0 +1,3 @@
{
"extends": "./tsconfig.json"
}

View File

@ -0,0 +1,5 @@
{
"extends": "tsconfig/base.json",
"include": ["src", "packup.config.ts"],
"exclude": ["node_modules"]
}

View File

@ -2,7 +2,21 @@ Copyright (c) 2015-present Strapi Solutions SAS
Portions of the Strapi software are licensed as follows:
* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined in "ee/LICENSE".
* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined below.
Enterprise License
If you or the company you represent has entered into a written agreement referencing the Enterprise Edition of the Strapi source code available at
https://github.com/strapi/strapi, then such agreement applies to your use of the Enterprise Edition of the Strapi Software. If you or the company you
represent is using the Enterprise Edition of the Strapi Software in connection with a subscription to our cloud offering, then the agreement you have
agreed to with respect to our cloud offering and the licenses included in such agreement apply to your use of the Enterprise Edition of the Strapi Software.
Otherwise, the Strapi Enterprise Software License Agreement (found here https://strapi.io/enterprise-terms) applies to your use of the Enterprise Edition of the Strapi Software.
BY ACCESSING OR USING THE ENTERPRISE EDITION OF THE STRAPI SOFTWARE, YOU ARE AGREEING TO BE BOUND BY THE RELEVANT REFERENCED AGREEMENT.
IF YOU ARE NOT AUTHORIZED TO ACCEPT THESE TERMS ON BEHALF OF THE COMPANY YOU REPRESENT OR IF YOU DO NOT AGREE TO ALL OF THE RELEVANT TERMS AND CONDITIONS REFERENCED AND YOU
HAVE NOT OTHERWISE EXECUTED A WRITTEN AGREEMENT WITH STRAPI, YOU ARE NOT AUTHORIZED TO ACCESS OR USE OR ALLOW ANY USER TO ACCESS OR USE ANY PART OF
THE ENTERPRISE EDITION OF THE STRAPI SOFTWARE. YOUR ACCESS RIGHTS ARE CONDITIONAL ON YOUR CONSENT TO THE RELEVANT REFERENCED TERMS TO THE EXCLUSION OF ALL OTHER TERMS;
IF THE RELEVANT REFERENCED TERMS ARE CONSIDERED AN OFFER BY YOU, ACCEPTANCE IS EXPRESSLY LIMITED TO THE RELEVANT REFERENCED TERMS.
* All software outside of the above-mentioned directories or restrictions above is available under the "MIT Expat" license as set forth below.
@ -18,5 +32,6 @@ furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,6 +1,6 @@
{
"name": "create-strapi-app",
"version": "5.0.0-beta.12",
"version": "5.0.0-rc.1",
"description": "Generate a new Strapi application.",
"keywords": [
"create-strapi-app",
@ -43,15 +43,17 @@
"watch": "pack-up watch"
},
"dependencies": {
"@strapi/generate-new": "5.0.0-beta.12",
"@strapi/cloud-cli": "5.0.0-rc.1",
"@strapi/generate-new": "5.0.0-rc.1",
"chalk": "4.1.2",
"commander": "8.3.0",
"inquirer": "8.2.5"
},
"devDependencies": {
"@strapi/pack-up": "5.0.0",
"@types/inquirer": "8.2.5",
"eslint-config-custom": "5.0.0-beta.12",
"tsconfig": "5.0.0-beta.12"
"eslint-config-custom": "5.0.0-rc.1",
"tsconfig": "5.0.0-rc.1"
},
"engines": {
"node": ">=18.0.0 <=20.x.x",

View File

@ -0,0 +1,107 @@
import inquirer from 'inquirer';
import { resolve } from 'node:path';
import { cli as cloudCli, services as cloudServices } from '@strapi/cloud-cli';
import parseToChalk from './utils/parse-to-chalk';
interface CloudError {
response: {
status: number;
data: string | object;
};
}
function assertCloudError(e: unknown): asserts e is CloudError {
if ((e as CloudError).response === undefined) {
throw Error('Expected CloudError');
}
}
export async function handleCloudProject(projectName: string): Promise<void> {
const logger = cloudServices.createLogger({
silent: false,
debug: process.argv.includes('--debug'),
timestamp: false,
});
let cloudApiService = await cloudServices.cloudApiFactory();
const defaultErrorMessage =
'An error occurred while trying to interact with Strapi Cloud. Use strapi deploy command once the project is generated.';
try {
const { data: config } = await cloudApiService.config();
logger.log(parseToChalk(config.projectCreation.introText));
} catch (e: unknown) {
logger.debug(e);
logger.error(defaultErrorMessage);
return;
}
const { userChoice } = await inquirer.prompt<{ userChoice: string }>([
{
type: 'list',
name: 'userChoice',
message: `Please log in or sign up.`,
choices: ['Login/Sign up', 'Skip'],
},
]);
if (userChoice !== 'Skip') {
const cliContext = {
logger,
cwd: process.cwd(),
};
const projectCreationSpinner = logger.spinner('Creating project on Strapi Cloud');
try {
const tokenService = await cloudServices.tokenServiceFactory(cliContext);
const loginSuccess = await cloudCli.login.action(cliContext);
if (!loginSuccess) {
return;
}
logger.debug('Retrieving token');
const token = await tokenService.retrieveToken();
cloudApiService = await cloudServices.cloudApiFactory(token);
logger.debug('Retrieving config');
const { data: config } = await cloudApiService.config();
logger.debug('config', config);
const defaultProjectValues = config.projectCreation?.defaults || {};
logger.debug('default project values', defaultProjectValues);
projectCreationSpinner.start();
const { data: project } = await cloudApiService.createProject({
nodeVersion: process.versions?.node?.slice(1, 3) || '20',
region: 'NYC',
plan: 'trial',
...defaultProjectValues,
name: projectName,
});
projectCreationSpinner.succeed('Project created on Strapi Cloud');
const projectPath = resolve(projectName);
logger.debug(project, projectPath);
await cloudServices.local.save({ project }, { directoryPath: projectPath });
} catch (e: Error | CloudError | unknown) {
logger.debug(e);
try {
assertCloudError(e);
if (e.response.status === 403) {
const message =
typeof e.response.data === 'string'
? e.response.data
: 'We are sorry, but we are not able to create a Strapi Cloud project for you at the moment.';
if (projectCreationSpinner.isSpinning) {
projectCreationSpinner.fail(message);
} else {
logger.warn(message);
}
return;
}
} catch (e) {
/* empty */
}
if (projectCreationSpinner.isSpinning) {
projectCreationSpinner.fail(defaultErrorMessage);
} else {
logger.error(defaultErrorMessage);
}
}
}
}

View File

@ -1,12 +1,14 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import commander from 'commander';
import { generateNewApp, type Options as GenerateNewAppOptions } from '@strapi/generate-new';
import * as prompts from './prompts';
import type { Options } from './types';
import { detectPackageManager } from './package-manager';
import * as database from './database';
// import { handleCloudProject } from './cloud';
const packageJson = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8'));
@ -29,6 +31,8 @@ command
.option('--use-pnpm', 'Use pnpm as the project package manager')
// Database options
// TODO V5: Uncomment when cloud-cli is ready
// .option('--skip-cloud', 'Skip cloud login and project creation')
.option('--dbclient <dbclient>', 'Database client')
.option('--dbhost <dbhost>', 'Database host')
.option('--dbport <dbport>', 'Database port')
@ -39,7 +43,7 @@ command
.option('--dbfile <dbfile>', 'Database file path for sqlite')
// templates
.option('--template <templateurl>', 'Specify a Strapi template')
// .option('--template <templateurl>', 'Specify a Strapi template')
.description('create a new application')
.action((directory, options) => {
createStrapiApp(directory, options);
@ -56,11 +60,17 @@ async function createStrapiApp(directory: string | undefined, options: Options)
const appDirectory = directory || (await prompts.directory());
// TODO V5: Uncomment when cloud-cli is ready
// if (!options.skipCloud) {
// checkRequirements();
// await handleCloudProject(projectName);
// }
const appOptions = {
directory: appDirectory,
useTypescript: true,
packageManager: 'npm',
template: options.template,
// template: options.template,
isQuickstart: options.quickstart,
} as GenerateNewAppOptions;
@ -101,16 +111,16 @@ async function createStrapiApp(directory: string | undefined, options: Options)
}
async function validateOptions(options: Options) {
const programFlags = command
.createHelp()
.visibleOptions(command)
.reduce<Array<string | undefined>>((acc, { short, long }) => [...acc, short, long], [])
.filter(Boolean);
// const programFlags = command
// .createHelp()
// .visibleOptions(command)
// .reduce<Array<string | undefined>>((acc, { short, long }) => [...acc, short, long], [])
// .filter(Boolean);
if (options.template && programFlags.includes(options.template)) {
console.error(`${options.template} is not a valid template`);
process.exit(1);
}
// if (options.template && programFlags.includes(options.template)) {
// console.error(`${options.template} is not a valid template`);
// process.exit(1);
// }
if (options.javascript === true && options.typescript === true) {
console.error('You cannot use both --typescript (--ts) and --javascript (--js) flags together');

View File

@ -5,6 +5,7 @@ export interface Options {
quickstart?: boolean;
run?: boolean;
dbclient?: DBClient;
skipCloud?: boolean;
dbhost?: string;
dbport?: string;
dbname?: string;
@ -12,7 +13,7 @@ export interface Options {
dbpassword?: string;
dbssl?: string;
dbfile?: string;
template?: string;
// template?: string;
typescript?: boolean;
javascript?: boolean;
}

View File

@ -0,0 +1,24 @@
import chalk from 'chalk';
// TODO: move styles to API
const supportedStyles = {
magentaBright: chalk.magentaBright,
blueBright: chalk.blueBright,
yellowBright: chalk.yellowBright,
green: chalk.green,
red: chalk.red,
bold: chalk.bold,
italic: chalk.italic,
};
export default function parseToChalk(template: string) {
let result = template;
for (const [color, chalkFunction] of Object.entries(supportedStyles)) {
const regex = new RegExp(`{${color}}(.*?){/${color}}`, 'g');
result = result.replace(regex, (_, p1) => chalkFunction(p1.trim()));
}
return result;
}

View File

@ -2,7 +2,21 @@ Copyright (c) 2015-present Strapi Solutions SAS
Portions of the Strapi software are licensed as follows:
* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined in "ee/LICENSE".
* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined below.
Enterprise License
If you or the company you represent has entered into a written agreement referencing the Enterprise Edition of the Strapi source code available at
https://github.com/strapi/strapi, then such agreement applies to your use of the Enterprise Edition of the Strapi Software. If you or the company you
represent is using the Enterprise Edition of the Strapi Software in connection with a subscription to our cloud offering, then the agreement you have
agreed to with respect to our cloud offering and the licenses included in such agreement apply to your use of the Enterprise Edition of the Strapi Software.
Otherwise, the Strapi Enterprise Software License Agreement (found here https://strapi.io/enterprise-terms) applies to your use of the Enterprise Edition of the Strapi Software.
BY ACCESSING OR USING THE ENTERPRISE EDITION OF THE STRAPI SOFTWARE, YOU ARE AGREEING TO BE BOUND BY THE RELEVANT REFERENCED AGREEMENT.
IF YOU ARE NOT AUTHORIZED TO ACCEPT THESE TERMS ON BEHALF OF THE COMPANY YOU REPRESENT OR IF YOU DO NOT AGREE TO ALL OF THE RELEVANT TERMS AND CONDITIONS REFERENCED AND YOU
HAVE NOT OTHERWISE EXECUTED A WRITTEN AGREEMENT WITH STRAPI, YOU ARE NOT AUTHORIZED TO ACCESS OR USE OR ALLOW ANY USER TO ACCESS OR USE ANY PART OF
THE ENTERPRISE EDITION OF THE STRAPI SOFTWARE. YOUR ACCESS RIGHTS ARE CONDITIONAL ON YOUR CONSENT TO THE RELEVANT REFERENCED TERMS TO THE EXCLUSION OF ALL OTHER TERMS;
IF THE RELEVANT REFERENCED TERMS ARE CONSIDERED AN OFFER BY YOU, ACCEPTANCE IS EXPRESSLY LIMITED TO THE RELEVANT REFERENCED TERMS.
* All software outside of the above-mentioned directories or restrictions above is available under the "MIT Expat" license as set forth below.
@ -18,5 +32,6 @@ furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,6 +1,6 @@
{
"name": "create-strapi",
"version": "5.0.0-beta.12",
"version": "5.0.0-rc.1",
"description": "Generate a new Strapi application.",
"keywords": [
"create-strapi",
@ -36,7 +36,7 @@
"bin/"
],
"dependencies": {
"create-strapi-app": "5.0.0-beta.12"
"create-strapi-app": "5.0.0-rc.1"
},
"engines": {
"node": ">=18.0.0 <=20.x.x",

View File

@ -2,7 +2,21 @@ Copyright (c) 2015-present Strapi Solutions SAS
Portions of the Strapi software are licensed as follows:
* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined in "ee/LICENSE".
* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined below.
Enterprise License
If you or the company you represent has entered into a written agreement referencing the Enterprise Edition of the Strapi source code available at
https://github.com/strapi/strapi, then such agreement applies to your use of the Enterprise Edition of the Strapi Software. If you or the company you
represent is using the Enterprise Edition of the Strapi Software in connection with a subscription to our cloud offering, then the agreement you have
agreed to with respect to our cloud offering and the licenses included in such agreement apply to your use of the Enterprise Edition of the Strapi Software.
Otherwise, the Strapi Enterprise Software License Agreement (found here https://strapi.io/enterprise-terms) applies to your use of the Enterprise Edition of the Strapi Software.
BY ACCESSING OR USING THE ENTERPRISE EDITION OF THE STRAPI SOFTWARE, YOU ARE AGREEING TO BE BOUND BY THE RELEVANT REFERENCED AGREEMENT.
IF YOU ARE NOT AUTHORIZED TO ACCEPT THESE TERMS ON BEHALF OF THE COMPANY YOU REPRESENT OR IF YOU DO NOT AGREE TO ALL OF THE RELEVANT TERMS AND CONDITIONS REFERENCED AND YOU
HAVE NOT OTHERWISE EXECUTED A WRITTEN AGREEMENT WITH STRAPI, YOU ARE NOT AUTHORIZED TO ACCESS OR USE OR ALLOW ANY USER TO ACCESS OR USE ANY PART OF
THE ENTERPRISE EDITION OF THE STRAPI SOFTWARE. YOUR ACCESS RIGHTS ARE CONDITIONAL ON YOUR CONSENT TO THE RELEVANT REFERENCED TERMS TO THE EXCLUSION OF ALL OTHER TERMS;
IF THE RELEVANT REFERENCED TERMS ARE CONSIDERED AN OFFER BY YOU, ACCEPTANCE IS EXPRESSLY LIMITED TO THE RELEVANT REFERENCED TERMS.
* All software outside of the above-mentioned directories or restrictions above is available under the "MIT Expat" license as set forth below.
@ -18,5 +32,6 @@ furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -19,7 +19,12 @@ import { Router, StrapiAppSetting, UnloadedSettingsLink } from './core/apis/rout
import { RootState, Store, configureStore } from './core/store/configure';
import { getBasename } from './core/utils/basename';
import { Handler, createHook } from './core/utils/createHook';
import { THEME_LOCAL_STORAGE_KEY, LANGUAGE_LOCAL_STORAGE_KEY, ThemeName } from './reducer';
import {
THEME_LOCAL_STORAGE_KEY,
LANGUAGE_LOCAL_STORAGE_KEY,
ThemeName,
getStoredToken,
} from './reducer';
import { getInitialRoutes } from './router';
import { languageNativeNames } from './translations/languageNativeNames';
@ -439,6 +444,7 @@ class StrapiApp {
locale: localeNames[locale] ? locale : 'en',
localeNames,
},
token: getStoredToken(),
},
},
this.middlewares,

View File

@ -3,12 +3,13 @@ import * as React from 'react';
import { Button, ButtonProps, Dialog } from '@strapi/design-system';
import { WarningCircle } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { styled } from 'styled-components';
/* -------------------------------------------------------------------------------------------------
* ConfirmDialog
* -----------------------------------------------------------------------------------------------*/
interface ConfirmDialogProps extends Pick<ButtonProps, 'variant'>, Pick<Dialog.BodyProps, 'icon'> {
onConfirm?: () => Promise<void> | void;
onConfirm?: (e?: React.MouseEvent<HTMLButtonElement>) => Promise<void> | void;
children?: React.ReactNode;
endAction?: React.ReactNode;
startAction?: React.ReactNode;
@ -49,7 +50,7 @@ interface ConfirmDialogProps extends Pick<ButtonProps, 'variant'>, Pick<Dialog.B
*/
const ConfirmDialog = ({
children,
icon = <WarningCircle width="24px" height="24px" fill="danger600" />,
icon = <StyledWarning />,
onConfirm,
variant = 'danger-light',
startAction,
@ -66,14 +67,14 @@ const ConfirmDialog = ({
defaultMessage: 'Are you sure?',
});
const handleConfirm = async () => {
const handleConfirm = async (e: React.MouseEvent<HTMLButtonElement>) => {
if (!onConfirm) {
return;
}
try {
setIsConfirming(true);
await onConfirm();
await onConfirm(e);
} finally {
setIsConfirming(false);
}
@ -92,7 +93,13 @@ const ConfirmDialog = ({
<Dialog.Footer>
{startAction || (
<Dialog.Cancel>
<Button fullWidth variant="tertiary">
<Button
fullWidth
variant="tertiary"
onClick={(e) => {
e.stopPropagation();
}}
>
{formatMessage({
id: 'app.components.Button.cancel',
defaultMessage: 'Cancel',
@ -115,5 +122,14 @@ const ConfirmDialog = ({
);
};
const StyledWarning = styled(WarningCircle)`
width: 24px;
height: 24px;
path {
fill: ${({ theme }) => theme.colors.danger600};
}
`;
export { ConfirmDialog };
export type { ConfirmDialogProps };

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import { Box, Button, Flex, Popover, Tag, useComposedRefs } from '@strapi/design-system';
import { Box, Button, Flex, Popover, Tag } from '@strapi/design-system';
import { Plus, Filter as FilterIcon, Cross } from '@strapi/icons';
import { Schema } from '@strapi/types';
import { useIntl } from 'react-intl';
@ -12,6 +12,7 @@ import {
IS_SENSITIVE_FILTERS,
NUMERIC_FILTERS,
STRING_PARSE_FILTERS,
FILTERS_WITH_NO_VALUE,
} from '../constants/filters';
import { useControllableState } from '../hooks/useControllableState';
import { useQueryParams } from '../hooks/useQueryParams';
@ -119,7 +120,9 @@ const PopoverImpl = () => {
}
const handleSubmit = (data: FilterFormData) => {
if (!data.value) {
const value = FILTERS_WITH_NO_VALUE.includes(data.filter) ? 'true' : data.value;
if (!value) {
return;
}
@ -130,12 +133,12 @@ const PopoverImpl = () => {
/**
* There will ALWAYS be an option because we use the options to create the form data.
*/
const filterType = options.find((filter) => filter.name === data.name)!.type;
const fieldOptions = options.find((filter) => filter.name === data.name)!;
/**
* If the filter is a relation, we need to nest the filter object,
* we always use ids to filter relations. But the nested object is
* the operator & value pair. This value _could_ look like:
* we filter based on the mainField of the relation, if there is no mainField, we use the id.
* At the end, we pass the operator & value. This value _could_ look like:
* ```json
* {
* "$eq": "1",
@ -143,7 +146,7 @@ const PopoverImpl = () => {
* ```
*/
const operatorValuePairing = {
[data.filter]: data.value,
[data.filter]: value,
};
const newFilterQuery = {
@ -152,9 +155,9 @@ const PopoverImpl = () => {
...(query.filters?.$and ?? []),
{
[data.name]:
filterType === 'relation'
fieldOptions.type === 'relation'
? {
id: operatorValuePairing,
[fieldOptions.mainField?.name ?? 'id']: operatorValuePairing,
}
: operatorValuePairing,
},
@ -266,7 +269,6 @@ const getFilterList = (filter?: Filters.Filter): FilterOption[] => {
switch (type) {
case 'email':
case 'text':
case 'enumeration':
case 'string': {
return [
...BASE_FILTERS,
@ -291,6 +293,10 @@ const getFilterList = (filter?: Filters.Filter): FilterOption[] => {
return [...BASE_FILTERS, ...NUMERIC_FILTERS];
}
case 'enumeration': {
return BASE_FILTERS;
}
default:
return [...BASE_FILTERS, ...IS_SENSITIVE_FILTERS];
}

View File

@ -619,7 +619,7 @@ const reducer = <TFormValues extends FormValues = FormValues>(
draft.values = setIn(
state.values,
action.payload.field,
newValue.length > 0 ? newValue : undefined
newValue.length > 0 ? newValue : []
);
break;
@ -670,45 +670,16 @@ const useField = <TValue = any,>(path: string): FieldValue<TValue | undefined> =
const handleChange = useForm('useField', (state) => state.onChange);
const formatNestedErrorMessages = (stateErrors: FormErrors<FormValues>) => {
const nestedErrors: Record<string, any> = {};
const error = useForm('useField', (state) => {
const error = getIn(state.errors, path);
Object.entries(stateErrors).forEach(([key, value]) => {
let current = nestedErrors;
if (isErrorMessageDescriptor(error)) {
const { values, ...message } = error;
return formatMessage(message, values);
}
const pathParts = key.split('.');
pathParts.forEach((part, index) => {
const isLastPart = index === pathParts.length - 1;
if (isLastPart) {
if (typeof value === 'string') {
// If the value is a translation message object or a string, it should be nested as is
current[part] = value;
} else if (isErrorMessageDescriptor(value)) {
// If the value is a plain object, it should be converted to a string message
current[part] = formatMessage(value);
} else {
// If the value is not an object, it may be an array or a message
setIn(current, part, value);
}
} else {
// Ensure nested structure exists
if (!current[part]) {
const isArray = !isNaN(Number(pathParts[index + 1]));
current[part] = isArray ? [] : {};
}
current = current[part];
}
});
});
return nestedErrors;
};
const error = useForm('useField', (state) =>
getIn(formatNestedErrorMessages(state.errors), path)
);
return error;
});
return {
initialValue,

View File

@ -25,10 +25,10 @@ const DateInput = forwardRef<HTMLInputElement, InputProps>(
ref={composedRefs}
clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })}
onChange={(date) => {
field.onChange(name, date);
field.onChange(name, date ? convertLocalDateToUTCDate(date) : null);
}}
onClear={() => field.onChange(name, undefined)}
value={value ? convertLocalDateToUTCDate(value) : undefined}
onClear={() => field.onChange(name, null)}
value={value ? convertLocalDateToUTCDate(value) : value}
{...props}
/>
<Field.Hint />

View File

@ -11,7 +11,7 @@ import { InputProps } from './types';
const DateTimeInput = forwardRef<HTMLInputElement, InputProps>(
({ name, required, label, hint, labelAction, ...props }, ref) => {
const { formatMessage } = useIntl();
const field = useField<Date>(name);
const field = useField<Date | null>(name);
const fieldRef = useFocusInputField<HTMLInputElement>(name);
const composedRefs = useComposedRefs(ref, fieldRef);
@ -24,9 +24,9 @@ const DateTimeInput = forwardRef<HTMLInputElement, InputProps>(
ref={composedRefs}
clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })}
onChange={(date) => {
field.onChange(name, date);
field.onChange(name, date ? date : null);
}}
onClear={() => field.onChange(name, undefined)}
onClear={() => field.onChange(name, null)}
value={value}
{...props}
/>

View File

@ -36,6 +36,7 @@ const InputRenderer = memo(
case 'biginteger':
case 'timestamp':
case 'string':
case 'uid':
return <StringInput ref={forwardRef} {...props} />;
case 'boolean':
return <BooleanInput ref={forwardRef} {...props} />;

View File

@ -36,7 +36,6 @@ interface InputProps {
| 'media'
| 'blocks'
| 'richtext'
| 'uid'
| 'dynamiczone'
| 'component'
| 'relation'

View File

@ -1,17 +1,16 @@
import { Box, BoxComponent, VisuallyHidden } from '@strapi/design-system';
import { Box, Flex, type FlexComponent, VisuallyHidden } from '@strapi/design-system';
import { useIntl } from 'react-intl';
import { styled } from 'styled-components';
import { useConfiguration } from '../../features/Configuration';
const BrandIconWrapper = styled<BoxComponent>(Box)`
const BrandIconWrapper = styled<FlexComponent>(Flex)`
svg,
img {
border-radius: ${({ theme }) => theme.borderRadius};
object-fit: contain;
height: 2.4rem;
width: 2.4rem;
margin: 0.4rem;
}
`;
@ -22,13 +21,20 @@ export const NavBrand = () => {
} = useConfiguration('LeftMenu');
return (
<Box padding={3}>
<BrandIconWrapper width="3.2rem" height="3.2rem">
<BrandIconWrapper
flexDirection="column"
justifyContent="center"
width="3.2rem"
height="3.2rem"
>
<img
src={menu.custom?.url || menu.default}
alt={formatMessage({
id: 'app.components.LeftMenu.logo.alt',
defaultMessage: 'Application logo',
})}
width="100%"
height="100%"
/>
<VisuallyHidden>
<span>

View File

@ -55,7 +55,7 @@ const LinkImpl = ({ children, ...props }: LinkProps) => {
* -----------------------------------------------------------------------------------------------*/
const TooltipImpl = ({ children, label, position = 'right' }: NavLink.TooltipProps) => {
return (
<Tooltip side={position} label={label}>
<Tooltip side={position} label={label} delayDuration={0}>
<span>{children}</span>
</Tooltip>
);

View File

@ -18,6 +18,12 @@ export interface NavUserProps extends ButtonProps {
*/
const MenuTrigger = styled(Menu.Trigger)`
height: 100%;
border-radius: 0;
border-width: 1px 0 0 0;
border-color: ${({ theme }) => theme.colors.neutral150};
border-style: solid;
// Prevent empty pixel from appearing below the main nav
overflow: hidden;
`;
const MenuContent = styled(Menu.Content)`
@ -45,6 +51,7 @@ export const NavUser = ({ children, initials, ...props }: NavUserProps) => {
logout();
navigate('/auth/login');
};
return (
<Flex justifyContent="center" {...props}>
<Menu.Root>

View File

@ -59,9 +59,9 @@ const FieldWrapper = styled(Field.Root)`
const delays = {
postResponse: 90 * 24 * 60 * 60 * 1000, // 90 days in ms
postFirstDismissal: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
postFirstDismissal: 14 * 24 * 60 * 60 * 1000, // 14 days in ms
postSubsequentDismissal: 90 * 24 * 60 * 60 * 1000, // 90 days in ms
display: 5 * 60 * 1000, // 5 minutes in ms
display: 30 * 60 * 1000, // 30 minutes in ms
};
const ratingArray = [...Array(11).keys()];

View File

@ -137,11 +137,12 @@ const NoData = (props: NoDataProps) => {
const { formatMessage } = useIntl();
return (
<PageMain height="100%">
<PageMain height="100%" background="neutral100">
<Flex alignItems="center" height="100%" width="100%" justifyContent="center">
<Box minWidth="50%">
<EmptyStateLayout
icon={<EmptyDocuments width="16rem" />}
action={props.action}
content={formatMessage({
id: 'app.components.EmptyStateLayout.content-document',
defaultMessage: 'No content found',

View File

@ -15,8 +15,22 @@ describe('useField hook', () => {
it('formats and returns nested error messages correctly for field constraints', () => {
const expectedError = 'This attribute must be unique';
const initialErrors = {
'repeatable.0.nestedUnique.TextShort': 'Another error message',
'repeatable.1.nestedUnique.nestedLevelOne.nestedLevelTwo.Unique': expectedError,
repeatable: [
{
nestedUnique: {
TextShort: 'Another error message',
},
},
{
nestedUnique: {
nestedLevelOne: {
nestedLevelTwo: {
Unique: expectedError,
},
},
},
},
],
};
const { result } = renderHook(
@ -35,7 +49,9 @@ describe('useField hook', () => {
defaultMessage: 'This attribute must be unique',
};
const initialErrors = {
'nested.uniqueAttribute': messageDescriptor,
nested: {
uniqueAttribute: messageDescriptor,
},
};
const { result } = renderHook(() => useField('nested.uniqueAttribute'), {
@ -51,9 +67,11 @@ describe('useField hook', () => {
defaultMessage: 'Mixed error message',
};
const initialErrors = {
'mixed.errorField': messageDescriptor,
'mixed.stringError': 'String error message',
'mixed.otherError': 123, // Non-string, non-descriptor error
mixed: {
errorField: messageDescriptor,
stringError: 'String error message',
otherError: 123, // Non-string, non-descriptor error
},
};
const { result } = renderHook(() => useField('mixed.otherError'), {
@ -65,8 +83,14 @@ describe('useField hook', () => {
it('handles errors associated with array indices', () => {
const initialErrors = {
'array.0.field': 'Error on first array item',
'array.1.field': 'Error on second array item',
array: [
{
field: 'Error on first array item',
},
{
field: 'Error on second array item',
},
],
};
const { result } = renderHook(() => useField('array.0.field'), {
@ -88,7 +112,9 @@ describe('useField hook', () => {
it('returns undefined for non-existent error paths', () => {
const initialErrors = {
'valid.path': 'Error message',
valid: {
path: 'Error message',
},
};
const { result } = renderHook(() => useField('invalid.path'), {

View File

@ -239,8 +239,8 @@ describe('NPS survey', () => {
it('respects the delay after first user dismissal', async () => {
const initialDate = new Date('2020-01-01');
const withinDelay = new Date('2020-01-04');
const beyondDelay = new Date('2020-01-08');
const withinDelay = new Date('2020-01-08');
const beyondDelay = new Date('2020-01-15');
localStorageMock.getItem.mockImplementation((key) => {
if (key === NPS_KEY) {

View File

@ -158,11 +158,14 @@ const STRING_PARSE_FILTERS = [
},
] satisfies FilterOption[];
const FILTERS_WITH_NO_VALUE = ['$null', '$notNull'];
export {
BASE_FILTERS,
NUMERIC_FILTERS,
IS_SENSITIVE_FILTERS,
CONTAINS_FILTERS,
STRING_PARSE_FILTERS,
FILTERS_WITH_NO_VALUE,
};
export type { FilterOption };

View File

@ -4,9 +4,11 @@ import {
Middleware,
Reducer,
combineReducers,
MiddlewareAPI,
isRejected,
} from '@reduxjs/toolkit';
import { reducer as appReducer, AppState } from '../../reducer';
import { reducer as appReducer, AppState, logout } from '../../reducer';
import { adminApi } from '../../services/api';
/**
@ -73,6 +75,7 @@ const configureStoreImpl = (
devTools: process.env.NODE_ENV !== 'production',
middleware: (getDefaultMiddleware) => [
...getDefaultMiddleware(defaultMiddlewareOptions),
rtkQueryUnauthorizedMiddleware,
adminApi.middleware,
...appMiddlewares.map((m) => m()),
],
@ -82,6 +85,20 @@ const configureStoreImpl = (
return store;
};
const rtkQueryUnauthorizedMiddleware: Middleware =
({ dispatch }: MiddlewareAPI) =>
(next) =>
(action) => {
// isRejectedWithValue Or isRejected
if (isRejected(action) && action.payload?.status === 401) {
dispatch(logout());
window.location.href = '/admin/auth/login';
return;
}
return next(action);
};
type Store = ReturnType<typeof configureStoreImpl> & {
asyncReducers: Record<string, Reducer>;
injectReducer: (key: string, asyncReducer: Reducer) => void;

View File

@ -4,9 +4,9 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { Login } from '../../../shared/contracts/authentication';
import { createContext } from '../components/Context';
import { useTypedDispatch } from '../core/store/hooks';
import { useTypedDispatch, useTypedSelector } from '../core/store/hooks';
import { useStrapiApp } from '../features/StrapiApp';
import { setLocale } from '../reducer';
import { login as loginAction, logout as logoutAction, setLocale } from '../reducer';
import { adminApi } from '../services/api';
import {
useGetMeQuery,
@ -50,7 +50,6 @@ interface AuthContextValue {
isLoading: boolean;
permissions: Permission[];
refetchPermissions: () => Promise<void>;
setToken: (token: string | null) => void;
token: string | null;
user?: User;
}
@ -63,6 +62,9 @@ interface AuthProviderProps {
* @internal could be removed at any time.
*/
_defaultPermissions?: Permission[];
// NOTE: this is used for testing purposed only
_disableRenewToken?: boolean;
}
const STORAGE_KEYS = {
@ -70,20 +72,15 @@ const STORAGE_KEYS = {
USER: 'userInfo',
};
const AuthProvider = ({ children, _defaultPermissions = [] }: AuthProviderProps) => {
const AuthProvider = ({
children,
_defaultPermissions = [],
_disableRenewToken = false,
}: AuthProviderProps) => {
const dispatch = useTypedDispatch();
const runRbacMiddleware = useStrapiApp('AuthProvider', (state) => state.rbac.run);
const location = useLocation();
const [token, setToken] = React.useState<string | null>(() => {
const token =
localStorage.getItem(STORAGE_KEYS.TOKEN) ?? sessionStorage.getItem(STORAGE_KEYS.TOKEN);
if (typeof token === 'string') {
return JSON.parse(token);
}
return null;
});
const token = useTypedSelector((state) => state.admin_app.token ?? null);
const { data: user, isLoading: isLoadingUser } = useGetMeQuery(undefined, {
/**
@ -92,6 +89,7 @@ const AuthProvider = ({ children, _defaultPermissions = [] }: AuthProviderProps)
*/
skip: !token,
});
const {
data: userPermissions = _defaultPermissions,
refetch,
@ -107,13 +105,11 @@ const AuthProvider = ({ children, _defaultPermissions = [] }: AuthProviderProps)
const [renewTokenMutation] = useRenewTokenMutation();
const [logoutMutation] = useLogoutMutation();
const clearStorage = React.useCallback(() => {
localStorage.removeItem(STORAGE_KEYS.TOKEN);
localStorage.removeItem(STORAGE_KEYS.USER);
sessionStorage.removeItem(STORAGE_KEYS.TOKEN);
sessionStorage.removeItem(STORAGE_KEYS.USER);
setToken(null);
}, []);
const clearStateAndLogout = React.useCallback(() => {
dispatch(adminApi.util.resetApiState());
dispatch(logoutAction());
navigate('/auth/login');
}, [dispatch, navigate]);
/**
* Fetch data from storages on mount and store it in our state.
@ -121,20 +117,20 @@ const AuthProvider = ({ children, _defaultPermissions = [] }: AuthProviderProps)
* does click "remember me" when they login. We also need to renew the token.
*/
React.useEffect(() => {
const token =
localStorage.getItem(STORAGE_KEYS.TOKEN) ?? sessionStorage.getItem(STORAGE_KEYS.TOKEN);
if (token) {
renewTokenMutation({ token: JSON.parse(token) }).then((res) => {
if (token && !_disableRenewToken) {
renewTokenMutation({ token }).then((res) => {
if ('data' in res) {
setToken(res.data.token);
dispatch(
loginAction({
token: res.data.token,
})
);
} else {
clearStorage();
navigate('/auth/login');
clearStateAndLogout();
}
});
}
}, [renewTokenMutation, clearStorage, navigate]);
}, [token, dispatch, renewTokenMutation, clearStateAndLogout, _disableRenewToken]);
React.useEffect(() => {
if (user) {
@ -144,20 +140,13 @@ const AuthProvider = ({ children, _defaultPermissions = [] }: AuthProviderProps)
}
}, [dispatch, user]);
React.useEffect(() => {
if (token) {
storeToken(token, false);
}
}, [token]);
React.useEffect(() => {
/**
* This will log a user out of all tabs if they log out in one tab.
*/
const handleUserStorageChange = (event: StorageEvent) => {
if (event.key === STORAGE_KEYS.USER && event.newValue === null) {
clearStorage();
navigate('/auth/login');
clearStateAndLogout();
}
};
@ -179,21 +168,23 @@ const AuthProvider = ({ children, _defaultPermissions = [] }: AuthProviderProps)
if ('data' in res) {
const { token } = res.data;
storeToken(token, rememberMe);
setToken(token);
dispatch(
loginAction({
token,
persist: rememberMe,
})
);
}
return res;
},
[loginMutation]
[dispatch, loginMutation]
);
const logout = React.useCallback(async () => {
await logoutMutation();
dispatch(adminApi.util.resetApiState());
clearStorage();
navigate('/auth/login');
}, [clearStorage, dispatch, logoutMutation, navigate]);
clearStateAndLogout();
}, [clearStateAndLogout, logoutMutation]);
const refetchPermissions = React.useCallback(async () => {
if (!isUninitialized) {
@ -272,7 +263,6 @@ const AuthProvider = ({ children, _defaultPermissions = [] }: AuthProviderProps)
permissions={userPermissions}
checkUserHasPermissions={checkUserHasPermissions}
refetchPermissions={refetchPermissions}
setToken={setToken}
isLoading={isLoading}
>
{children}
@ -280,12 +270,5 @@ const AuthProvider = ({ children, _defaultPermissions = [] }: AuthProviderProps)
);
};
const storeToken = (token: string, persist?: boolean) => {
if (!persist) {
return window.sessionStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token));
}
return window.localStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token));
};
export { AuthProvider, useAuth, STORAGE_KEYS };
export type { AuthContextValue, Permission, User };

View File

@ -78,6 +78,7 @@ const ConfigurationProvider = ({
(state) => state.admin_app.permissions.settings?.['project-settings']
);
const token = useAuth('ConfigurationProvider', (state) => state.token);
const {
allowedActions: { canRead },
} = useRBAC(permissions);

View File

@ -5,6 +5,7 @@ import { IntlFormatters, useIntl } from 'react-intl';
import { FetchError } from '../utils/getFetchClient';
import { getPrefixedId } from '../utils/getPrefixedId';
import { NormalizeErrorOptions, normalizeAPIError } from '../utils/normalizeAPIError';
import { setIn } from '../utils/objects';
import type { errors } from '@strapi/utils';
@ -97,7 +98,7 @@ that has been thrown.
* const { get } = useFetchClient();
* const { formatAPIError } = useAPIErrorHandler(getTrad);
* const { toggleNotification } = useNotification();
*
*
* const handleDeleteItem = async () => {
* try {
* return await get('/admin');
@ -156,10 +157,7 @@ export function useAPIErrorHandler(
return validationErrors.reduce((acc, err) => {
const { path, message } = err;
return {
...acc,
[path.join('.')]: message,
};
return setIn(acc, path.join('.'), message);
}, {});
} else {
const details = error.details as Record<string, string[]>;

View File

@ -47,7 +47,7 @@ const AdminLayout = () => {
fetch('https://api.github.com/repos/strapi/strapi/releases/latest')
.then(async (res) => {
if (!res.ok) {
throw new Error();
return;
}
const response = (await res.json()) as { tag_name: string | null | undefined };

View File

@ -17,11 +17,12 @@ import { InputRenderer } from '../../../components/FormInputs/Renderer';
import { useGuidedTour } from '../../../components/GuidedTour/Provider';
import { useNpsSurveySettings } from '../../../components/NpsSurvey';
import { Logo } from '../../../components/UnauthenticatedLogo';
import { useAuth } from '../../../features/Auth';
import { useTypedDispatch } from '../../../core/store/hooks';
import { useNotification } from '../../../features/Notifications';
import { useTracking } from '../../../features/Tracking';
import { useAPIErrorHandler } from '../../../hooks/useAPIErrorHandler';
import { LayoutContent, UnauthenticatedLayout } from '../../../layouts/UnauthenticatedLayout';
import { login } from '../../../reducer';
import {
useGetRegistrationInfoQuery,
useRegisterAdminMutation,
@ -192,7 +193,7 @@ const Register = ({ hasAdmin }: RegisterProps) => {
const [registerAdmin] = useRegisterAdminMutation();
const [registerUser] = useRegisterUserMutation();
const { setToken } = useAuth('Register', (auth) => auth);
const dispatch = useTypedDispatch();
const handleRegisterAdmin = async (
{ news, ...body }: RegisterAdmin.Request['body'] & { news: boolean },
@ -201,7 +202,7 @@ const Register = ({ hasAdmin }: RegisterProps) => {
const res = await registerAdmin(body);
if ('data' in res) {
setToken(res.data.token);
dispatch(login({ token: res.data.token }));
const { roles } = res.data.user;
@ -247,7 +248,7 @@ const Register = ({ hasAdmin }: RegisterProps) => {
const res = await registerUser(body);
if ('data' in res) {
setToken(res.data.token);
dispatch(login({ token: res.data.token }));
if (news) {
// Only enable EE survey if user accepted the newsletter

View File

@ -9,13 +9,14 @@ import { ResetPassword } from '../../../../../shared/contracts/authentication';
import { Form } from '../../../components/Form';
import { InputRenderer } from '../../../components/FormInputs/Renderer';
import { Logo } from '../../../components/UnauthenticatedLogo';
import { useAuth } from '../../../features/Auth';
import { useTypedDispatch } from '../../../core/store/hooks';
import { useAPIErrorHandler } from '../../../hooks/useAPIErrorHandler';
import {
Column,
LayoutContent,
UnauthenticatedLayout,
} from '../../../layouts/UnauthenticatedLayout';
import { login } from '../../../reducer';
import { useResetPasswordMutation } from '../../../services/auth';
import { isBaseQueryError } from '../../../utils/baseQuery';
import { translatedErrors } from '../../../utils/translatedErrors';
@ -64,20 +65,19 @@ const RESET_PASSWORD_SCHEMA = yup.object().shape({
const ResetPassword = () => {
const { formatMessage } = useIntl();
const dispatch = useTypedDispatch();
const navigate = useNavigate();
const { search: searchString } = useLocation();
const query = React.useMemo(() => new URLSearchParams(searchString), [searchString]);
const { _unstableFormatAPIError: formatAPIError } = useAPIErrorHandler();
const { setToken } = useAuth('ResetPassword', (auth) => auth);
const [resetPassword, { error }] = useResetPasswordMutation();
const handleSubmit = async (body: ResetPassword.Request['body']) => {
const res = await resetPassword(body);
if ('data' in res) {
setToken(res.data.token);
dispatch(login({ token: res.data.token }));
navigate('/');
}
};

View File

@ -176,6 +176,7 @@ const DefaultButton = ({
<LinkStyled
tag={NavLink}
to={tokenId.toString()}
onClick={(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => e.stopPropagation()}
title={formatMessage(MESSAGES_MAP[buttonType], { target: tokenName })}
>
{children}

View File

@ -19,7 +19,8 @@ import { useApiTokenPermissions } from '../apiTokenPermissions';
const activeCheckboxWrapperStyles = css`
background: ${(props) => props.theme.colors.primary100};
svg {
#cog {
opacity: 1;
}
`;
@ -29,7 +30,7 @@ const CheckboxWrapper = styled<BoxComponent>(Box)<{ $isActive: boolean }>`
justify-content: space-between;
align-items: center;
svg {
#cog {
opacity: 0;
path {
fill: ${(props) => props.theme.colors.primary600};
@ -137,7 +138,7 @@ export const CollapsableContentType = ({
}
style={{ display: 'inline-flex', alignItems: 'center' }}
>
<Cog />
<Cog id="cog" />
</button>
</CheckboxWrapper>
</Grid.Item>

View File

@ -81,7 +81,14 @@ const LogoInput = ({
};
return (
<Modal.Root>
<Modal.Root
open={!!currentStep}
onOpenChange={(state) => {
if (state === false) {
handleClose();
}
}}
>
<LogoInputContextProvider
setLocalImage={setLocalImage}
localImage={localImage}
@ -307,8 +314,12 @@ const ComputerForm = () => {
const { setLocalImage, goToStep, onClose } = useLogoInputContext('ComputerForm');
const handleDragEnter = () => setDragOver(true);
const handleDragLeave = () => setDragOver(false);
const handleDragEnter = () => {
setDragOver(true);
};
const handleDragLeave = () => {
setDragOver(false);
};
const handleClick: ButtonProps['onClick'] = (e) => {
e.preventDefault();
@ -367,15 +378,17 @@ const ComputerForm = () => {
})}
</Typography>
</Box>
<FileInput
accept={ACCEPTED_FORMAT.join(', ')}
type="file"
name="files"
tabIndex={-1}
onChange={handleChange}
ref={inputRef}
id={id}
/>
<Box position="relative">
<FileInput
accept={ACCEPTED_FORMAT.join(', ')}
type="file"
name="files"
tabIndex={-1}
onChange={handleChange}
ref={inputRef}
id={id}
/>
</Box>
<Button type="button" onClick={handleClick}>
{formatMessage({
id: 'Settings.application.customization.modal.upload.cta.browse',

View File

@ -14,7 +14,7 @@ import {
import { format } from 'date-fns';
import { Formik, Form, FormikHelpers } from 'formik';
import { useIntl } from 'react-intl';
import { useNavigate, useMatch } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { styled } from 'styled-components';
import * as yup from 'yup';
@ -58,7 +58,7 @@ interface CreateRoleFormValues {
* manage the state of the child is nonsensical.
*/
const CreatePage = () => {
const match = useMatch('/settings/roles/duplicate/:id');
const { id } = useParams();
const { toggleNotification } = useNotification();
const { formatMessage } = useIntl();
const navigate = useNavigate();
@ -69,9 +69,7 @@ const CreatePage = () => {
_unstableFormatValidationErrors: formatValidationErrors,
} = useAPIErrorHandler();
const id = match?.params.id ?? null;
const { isLoading: isLoadingPermissionsLayout, data: permissionsLayout } =
const { isLoading: isLoadingPermissionsLayout, currentData: permissionsLayout } =
useGetRolePermissionLayoutQuery({
/**
* Role here is a query param so if there's no role we pass an empty string
@ -84,7 +82,7 @@ const CreatePage = () => {
* We need this so if we're cloning a role, we can fetch
* the current permissions that role has.
*/
const { data: rolePermissions, isLoading: isLoadingRole } = useGetRolePermissionsQuery(
const { currentData: rolePermissions, isLoading: isLoadingRole } = useGetRolePermissionsQuery(
{
id: id!,
},
@ -150,7 +148,7 @@ const CreatePage = () => {
message: formatMessage({ id: 'Settings.roles.created', defaultMessage: 'created' }),
});
navigate(res.data.id.toString(), { replace: true });
navigate(`../roles/${res.data.id.toString()}`, { replace: true });
} catch (err) {
toggleNotification({
type: 'danger',

View File

@ -15,7 +15,6 @@ import { useIntl } from 'react-intl';
import { styled, DefaultTheme, css } from 'styled-components';
import { Action, SubjectProperty } from '../../../../../../../shared/contracts/permissions';
import { capitalise } from '../../../../../utils/strings';
import {
PermissionsDataManagerContextValue,
usePermissionsDataManager,
@ -68,7 +67,7 @@ const CollapsePropertyMatrix = ({
);
return (
<Flex display="inline-flex" direction="column" minWidth={0}>
<Flex display="inline-flex" direction="column" alignItems="stretch" minWidth={0}>
<Header label={label} headers={propertyActions} />
<Box>
{childrenForm.map(({ children: childrenForm, label, value, required }, i) => (
@ -225,7 +224,7 @@ const ActionRow = ({
},
});
}}
value={checkboxValue}
checked={checkboxValue}
/>
</Flex>
);
@ -322,15 +321,15 @@ const Wrapper = styled<FlexComponent>(Flex)<{ $isCollapsable?: boolean; $isActiv
height: ${rowHeight};
flex: 1;
${({ $isCollapsable, theme }) =>
&:hover {
${({ $isCollapsable, theme }) => $isCollapsable && activeStyle(theme)}
}
${({ $isCollapsable }) =>
$isCollapsable &&
`
${CarretIcon} {
display: block;
color: ${theme.colors.neutral100};
}
&:hover {
${activeStyle(theme)}
display: flex;
}
`}
${({ $isActive, theme }) => $isActive && activeStyle(theme)};
@ -338,7 +337,15 @@ const Wrapper = styled<FlexComponent>(Flex)<{ $isCollapsable?: boolean; $isActiv
const CarretIcon = styled(CaretDown)<{ $isActive: boolean }>`
display: none;
width: 1rem;
svg {
width: 1.4rem;
}
path {
fill: ${({ theme }) => theme.colors.neutral200};
}
transform: rotate(${({ $isActive }) => ($isActive ? '180' : '0')}deg);
margin-left: ${({ theme }) => theme.spaces[2]};
`;
@ -432,7 +439,7 @@ const SubActionRow = ({
})}
title={label}
>
<RowLabel ellipsis>{capitalise(label)}</RowLabel>
<RowLabel ellipsis>{label}</RowLabel>
{required && <RequiredSign />}
<CarretIcon $isActive={isActive} />
</CollapseLabel>
@ -485,7 +492,7 @@ const SubActionRow = ({
},
});
}}
value={checkboxValue}
checked={checkboxValue}
/>
</Flex>
);
@ -566,15 +573,15 @@ const RowStyle = styled<FlexComponent>(Flex)<{
padding-left: ${({ theme }) => theme.spaces[4]};
width: ${({ $level }) => 145 - $level * 36}px;
${({ $isCollapsable, theme }) =>
&:hover {
${({ $isCollapsable, theme }) => $isCollapsable && activeStyle(theme)}
}
${({ $isCollapsable }) =>
$isCollapsable &&
`
${CarretIcon} {
display: block;
color: ${theme.colors.neutral100};
}
&:hover {
${activeStyle(theme)}
display: flex;
}
`}
${({ $isActive, theme }) => $isActive && activeStyle(theme)};
@ -658,26 +665,15 @@ const Header = ({ headers = [], label }: HeaderProps) => {
);
};
/* -------------------------------------------------------------------------------------------------
* activeStyle (util)
* -----------------------------------------------------------------------------------------------*/
/**
* @internal
*/
const activeStyle = (theme: DefaultTheme) => css`
${RowLabel} {
color: ${theme.colors.primary600};
font-weight: ${theme.fontWeights.bold};
}
${CarretIcon} {
display: block;
color: ${theme.colors.primary600};
font-weight: ${theme.fontWeights.bold};
${CarretIcon} {
path {
fill: ${theme.colors.primary600};
}
}
`;
export { activeStyle as _internalActiveStyle };
export { CollapsePropertyMatrix };

View File

@ -41,12 +41,14 @@ interface ConditionAction extends Pick<ActionRowProps, 'label'> {
interface ConditionsModalProps extends Pick<ActionRowProps, 'isFormDisabled'> {
actions?: Array<ConditionAction | HiddenCheckboxAction | VisibleCheckboxAction>;
headerBreadCrumbs?: string[];
onClose?: () => void;
}
const ConditionsModal = ({
actions = [],
headerBreadCrumbs = [],
isFormDisabled,
onClose,
}: ConditionsModalProps) => {
const { formatMessage } = useIntl();
const { availableConditions, modifiedData, onChangeConditions } = usePermissionsDataManager();
@ -98,6 +100,15 @@ const ConditionsModal = ({
);
onChangeConditions(conditionsWithoutCategory);
onClose && onClose();
};
const onCloseModal = () => {
setState(
createDefaultConditionsForm(actionsToDisplay, modifiedData, arrayOfOptionsGroupedByCategory)
);
onClose && onClose();
};
return (
@ -146,11 +157,9 @@ const ConditionsModal = ({
</ul>
</Modal.Body>
<Modal.Footer>
<Modal.Close>
<Button variant="tertiary">
{formatMessage({ id: 'app.components.Button.cancel', defaultMessage: 'Cancel' })}
</Button>
</Modal.Close>
<Button variant="tertiary" onClick={() => onCloseModal()}>
{formatMessage({ id: 'app.components.Button.cancel', defaultMessage: 'Cancel' })}
</Button>
<Button onClick={handleSubmit}>
{formatMessage({
id: 'Settings.permissions.conditions.apply',

View File

@ -9,6 +9,7 @@ import { useIntl } from 'react-intl';
import { styled, DefaultTheme } from 'styled-components';
import { Action, Subject } from '../../../../../../../shared/contracts/permissions';
import { capitalise } from '../../../../../utils/strings';
import {
PermissionsDataManagerContextValue,
usePermissionsDataManager,
@ -18,7 +19,7 @@ import { createArrayOfValues } from '../utils/createArrayOfValues';
import { ConditionForm } from '../utils/forms';
import { getCheckboxState } from '../utils/getCheckboxState';
import { CollapsePropertyMatrix, _internalActiveStyle } from './CollapsePropertyMatrix';
import { CollapsePropertyMatrix } from './CollapsePropertyMatrix';
import { ConditionsButton } from './ConditionsButton';
import { ConditionsModal } from './ConditionsModal';
import { HiddenAction } from './HiddenAction';
@ -61,9 +62,9 @@ const ContentTypeCollapses = ({
key={uid}
direction="column"
display="inline-flex"
alignItems="stretch"
minWidth="100%"
borderColor="primary600"
borderWidth={isActive ? 1 : 0}
borderColor={isActive ? 'primary600' : undefined}
>
<Collapse
availableActions={availableActions}
@ -119,6 +120,7 @@ const Collapse = ({
const { formatMessage } = useIntl();
const { modifiedData, onChangeParentCheckbox, onChangeSimpleCheckbox } =
usePermissionsDataManager();
const [isConditionModalOpen, setIsConditionModalOpen] = React.useState(false);
// This corresponds to the data related to the CT left checkbox
// modifiedData: { collectionTypes: { [ctuid]: {create: {properties: { fields: {f1: true} }, update: {}, ... } } } }
@ -156,7 +158,7 @@ const Collapse = ({
<RowLabelWithCheckbox
isCollapsable
isFormDisabled={isFormDisabled}
label={label}
label={capitalise(label)}
checkboxName={pathToData}
onChange={onChangeParentCheckbox}
onClick={onClickToggle}
@ -257,7 +259,12 @@ const Collapse = ({
</Flex>
</Wrapper>
<Box bottom="10px" right="9px" position="absolute">
<Modal.Root>
<Modal.Root
open={isConditionModalOpen}
onOpenChange={() => {
setIsConditionModalOpen((prev) => !prev);
}}
>
<Modal.Trigger>
<ConditionsButton hasConditions={doesConditionButtonHasConditions} />
</Modal.Trigger>
@ -265,6 +272,9 @@ const Collapse = ({
headerBreadCrumbs={[label, 'Settings.permissions.conditions.conditions']}
actions={checkboxesActions}
isFormDisabled={isFormDisabled}
onClose={() => {
setIsConditionModalOpen(false);
}}
/>
</Modal.Root>
</Box>
@ -347,16 +357,15 @@ const activeRowStyle = (theme: DefaultTheme, isActive?: boolean): string => `
background-color: ${theme.colors.primary100};
color: ${theme.colors.primary600};
border-radius: ${isActive ? '2px 2px 0 0' : '2px'};
font-weight: ${theme.fontWeights.bold};
}
${Chevron} {
display: flex;
}
${ConditionsButton} {
display: block;
}
&:hover {
${_internalActiveStyle(theme)}
}
&:focus-within {
${() => activeRowStyle(theme, isActive)}
@ -390,10 +399,12 @@ const Cell = styled<FlexComponent>(Flex)`
const Chevron = styled<BoxComponent>(Box)`
display: none;
svg {
width: 11px;
width: 1.4rem;
}
* {
path {
fill: ${({ theme }) => theme.colors.primary600};
}
`;

View File

@ -1,5 +1,4 @@
import { Box, BoxComponent } from '@strapi/design-system';
import { styled } from 'styled-components';
import { Box } from '@strapi/design-system';
import { ContentPermission } from '../../../../../../../shared/contracts/permissions';
@ -19,7 +18,7 @@ const ContentTypes = ({
const sortedSubjects = [...subjects].sort((a, b) => a.label.localeCompare(b.label));
return (
<StyledBox background="neutral0">
<Box background="neutral0">
<GlobalActions actions={actions} kind={kind} isFormDisabled={isFormDisabled} />
<ContentTypeCollapses
actions={actions}
@ -27,12 +26,8 @@ const ContentTypes = ({
pathToData={kind}
subjects={sortedSubjects}
/>
</StyledBox>
</Box>
);
};
const StyledBox = styled<BoxComponent>(Box)`
overflow-x: auto;
`;
export { ContentTypes };

View File

@ -144,6 +144,7 @@ const SubCategory = ({
}: SubCategoryProps) => {
const { modifiedData, onChangeParentCheckbox, onChangeSimpleCheckbox } =
usePermissionsDataManager();
const [isConditionModalOpen, setIsConditionModalOpen] = React.useState(false);
const { formatMessage } = useIntl();
const mainData = get(modifiedData, pathToData, {});
@ -248,7 +249,12 @@ const SubCategory = ({
);
})}
</Grid.Root>
<Modal.Root>
<Modal.Root
open={isConditionModalOpen}
onOpenChange={() => {
setIsConditionModalOpen((prev) => !prev);
}}
>
<Modal.Trigger>
<ConditionsButton hasConditions={doesButtonHasCondition} />
</Modal.Trigger>
@ -256,6 +262,9 @@ const SubCategory = ({
headerBreadCrumbs={[categoryName, subCategoryName]}
actions={formattedActions}
isFormDisabled={isFormDisabled}
onClose={() => {
setIsConditionModalOpen(false);
}}
/>
</Modal.Root>
</Flex>

View File

@ -3,7 +3,6 @@ import * as React from 'react';
import { Checkbox, Box, Flex, Typography } from '@strapi/design-system';
import { useIntl } from 'react-intl';
import { capitalise } from '../../../../../utils/strings';
import { PermissionsDataManagerContextValue } from '../hooks/usePermissionsDataManager';
import { firstRowWidth } from '../utils/constants';
@ -36,6 +35,26 @@ const RowLabelWithCheckbox = ({
}: RowLabelWithCheckboxProps) => {
const { formatMessage } = useIntl();
const collapseLabelProps = {
title: label,
alignItems: 'center',
$isCollapsable: isCollapsable,
};
if (isCollapsable) {
Object.assign(collapseLabelProps, {
onClick,
'aria-expanded': isActive,
onKeyDown({ key }: React.KeyboardEvent<HTMLDivElement>) {
if (key === 'Enter' || key === ' ') {
onClick();
}
},
tabIndex: 0,
role: 'button',
});
}
return (
<Flex alignItems="center" paddingLeft={6} width={firstRowWidth} shrink={0}>
<Box paddingRight={2}>
@ -61,26 +80,8 @@ const RowLabelWithCheckbox = ({
checked={someChecked ? 'indeterminate' : value}
/>
</Box>
<CollapseLabel
title={label}
alignItems="center"
$isCollapsable={isCollapsable}
{...(isCollapsable && {
onClick,
'aria-expanded': isActive,
onKeyDown: ({ key }: React.KeyboardEvent<HTMLDivElement>) =>
(key === 'Enter' || key === ' ') && onClick(),
tabIndex: 0,
role: 'button',
})}
>
<Typography
fontWeight={isActive ? 'bold' : undefined}
textColor={isActive ? 'primary600' : 'neutral800'}
ellipsis
>
{capitalise(label)}
</Typography>
<CollapseLabel {...collapseLabelProps}>
<Typography ellipsis>{label}</Typography>
{children}
</CollapseLabel>
</Flex>

View File

@ -35,7 +35,7 @@ describe('Permissions', () => {
COLLECTION_TYPES.forEach((type) =>
expect(
screen.getByRole('checkbox', { name: `Select all ${type} permissions` })
screen.getByRole('checkbox', { name: `Select all ${capitalise(type)} permissions` })
).toBeInTheDocument()
);
@ -73,7 +73,7 @@ describe('Permissions', () => {
});
});
await user.click(screen.getByRole('button', { name: 'Repeat_req_min' }));
await user.click(screen.getByRole('button', { name: 'repeat_req_min' }));
COLUMN_HEADERS.filter((head) => head !== 'Publish' && head !== 'Delete').forEach((head) => {
expect(

View File

@ -96,7 +96,7 @@ const EditPage = () => {
React.useEffect(() => {
if (error) {
// Redirect the use to the homepage if is not allowed to read
// Redirect the user to the homepage if is not allowed to read
if (error.name === 'UnauthorizedError') {
toggleNotification({
type: 'info',

View File

@ -1,12 +1,13 @@
import * as React from 'react';
import { Flex, Typography, Status, IconButton } from '@strapi/design-system';
import { Flex, Typography, Status, IconButton, Dialog } from '@strapi/design-system';
import { Pencil, Trash } from '@strapi/icons';
import * as qs from 'qs';
import { MessageDescriptor, useIntl } from 'react-intl';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { SanitizedAdminUser } from '../../../../../../shared/contracts/shared';
import { ConfirmDialog } from '../../../../components/ConfirmDialog';
import { Filters } from '../../../../components/Filters';
import { Layouts } from '../../../../components/Layouts/Layout';
import { Page } from '../../../../components/PageHelpers';
@ -39,6 +40,8 @@ const ListPageCE = () => {
const { toggleNotification } = useNotification();
const { formatMessage } = useIntl();
const { search } = useLocation();
const [showDeleteConfirmation, setShowDeleteConfirmation] = React.useState(false);
const [idsToDelete, setIdsToDelete] = React.useState<Array<SanitizedAdminUser['id']>>([]);
const { data, isError, isLoading } = useAdminUsers(qs.parse(search, { ignoreQueryPrefix: true }));
const { pagination, users = [] } = data ?? {};
@ -79,7 +82,6 @@ const ListPageCE = () => {
});
}
} catch (err) {
console.error(err);
toggleNotification({
type: 'danger',
message: formatMessage({
@ -96,8 +98,15 @@ const ListPageCE = () => {
}
};
const handleDeleteClick = (id: SanitizedAdminUser['id']) => async () =>
await handleDeleteAll([id]);
const handleDeleteClick = (id: SanitizedAdminUser['id']) => async () => {
setIdsToDelete([id]);
setShowDeleteConfirmation(true);
};
const confirmDelete = async () => {
await handleDeleteAll(idsToDelete);
setShowDeleteConfirmation(false);
};
// block rendering until the EE component is fully loaded
if (!CreateAction) {
@ -217,6 +226,9 @@ const ListPageCE = () => {
</Pagination.Root>
</Layouts.Content>
{isModalOpened && <ModalForm onToggle={handleToggle} />}
<Dialog.Root open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<ConfirmDialog onConfirm={confirmDelete} />
</Dialog.Root>
</Page.Main>
);
};

View File

@ -103,17 +103,12 @@ const ModalForm = ({ onToggle }: ModalFormProps) => {
});
if ('data' in res) {
// NOTE: when enabling SSO, the user doesn't have to register and the token is undefined
if (res.data.registrationToken) {
setRegistrationToken(res.data.registrationToken);
goNext();
} else {
// This shouldn't happen, but just incase.
toggleNotification({
type: 'danger',
message: formatMessage({ id: 'notification.error', defaultMessage: 'An error occured' }),
});
}
goNext();
} else {
toggleNotification({
type: 'danger',

View File

@ -45,6 +45,13 @@ const EditPage = () => {
_unstableFormatAPIError: formatAPIError,
_unstableFormatValidationErrors: formatValidationErrors,
} = useAPIErrorHandler();
/**
* Prevents the notifications from showing up twice because the function identity
* coming from the helper plugin is not stable
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableFormatAPIError = React.useCallback(formatAPIError, []);
const [isTriggering, setIsTriggering] = React.useState(false);
const [triggerResponse, setTriggerResponse] = React.useState<TriggerWebhook.Response['data']>();
@ -59,10 +66,10 @@ const EditPage = () => {
if (error) {
toggleNotification({
type: 'danger',
message: formatAPIError(error),
message: stableFormatAPIError(error),
});
}
}, [error, toggleNotification, formatAPIError]);
}, [error, toggleNotification, stableFormatAPIError]);
const handleTriggerWebhook = async () => {
try {

View File

@ -103,7 +103,34 @@ const ListPage = () => {
}
};
const confirmDelete = async () => {
const deleteWebhook = async (id: string) => {
try {
const res = await deleteManyWebhooks({
ids: [id],
});
if ('error' in res) {
toggleNotification({
type: 'danger',
message: formatAPIError(res.error),
});
return;
}
setWebhooksToDelete((prev) => prev.filter((webhookId) => webhookId !== id));
} catch {
toggleNotification({
type: 'danger',
message: formatMessage({
id: 'notification.error',
defaultMessage: 'An error occurred',
}),
});
}
};
const confirmBulkDelete = async () => {
try {
const res = await deleteManyWebhooks({
ids: webhooksToDelete,
@ -309,7 +336,7 @@ const ListPage = () => {
<Td>
<Typography textColor="neutral800">{webhook.url}</Typography>
</Td>
<Td>
<Td onClick={(e) => e.stopPropagation()}>
<Flex>
<Switch
onLabel={formatMessage({
@ -349,20 +376,11 @@ const ListPage = () => {
</IconButton>
)}
{canDelete && (
<IconButton
onClick={(e) => {
e.stopPropagation();
setWebhooksToDelete([webhook.id]);
setShowModal(true);
<DeleteActionButton
onDelete={() => {
deleteWebhook(webhook.id);
}}
label={formatMessage({
id: 'Settings.webhooks.events.delete',
defaultMessage: 'Delete webhook',
})}
borderWidth={0}
>
<Trash />
</IconButton>
/>
)}
</Flex>
</Td>
@ -392,12 +410,52 @@ const ListPage = () => {
</Layouts.Content>
</Page.Main>
<Dialog.Root open={showModal} onOpenChange={setShowModal}>
<ConfirmDialog onConfirm={confirmDelete} />
<ConfirmDialog onConfirm={confirmBulkDelete} />
</Dialog.Root>
</Layouts.Root>
);
};
/* -------------------------------------------------------------------------------------------------
* DeleteActionButton
* -----------------------------------------------------------------------------------------------*/
type DeleteActionButtonProps = {
onDelete: () => void;
};
const DeleteActionButton = ({ onDelete }: DeleteActionButtonProps) => {
const [showModal, setShowModal] = React.useState(false);
const { formatMessage } = useIntl();
return (
<>
<IconButton
onClick={(e) => {
e.stopPropagation();
setShowModal(true);
}}
label={formatMessage({
id: 'Settings.webhooks.events.delete',
defaultMessage: 'Delete webhook',
})}
borderWidth={0}
>
<Trash />
</IconButton>
<Dialog.Root open={showModal} onOpenChange={setShowModal}>
<ConfirmDialog
onConfirm={(e) => {
e?.stopPropagation();
onDelete();
}}
/>
</Dialog.Root>
</>
);
};
/* -------------------------------------------------------------------------------------------------
* ProtectedListView
* -----------------------------------------------------------------------------------------------*/

View File

@ -286,13 +286,15 @@ const EventsRow = ({
{events.map((event) => {
return (
<Td key={event} textAlign="center">
<Checkbox
disabled={disabledEvents.includes(event)}
aria-label={event}
name={event}
checked={inputValue.includes(event)}
onCheckedChange={(value) => handleSelect(event, !!value)}
/>
<Flex width="100%" justifyContent="center">
<Checkbox
disabled={disabledEvents.includes(event)}
aria-label={event}
name={event}
checked={inputValue.includes(event)}
onCheckedChange={(value) => handleSelect(event, !!value)}
/>
</Flex>
</Td>
);
})}

View File

@ -13,10 +13,15 @@ import {
} from '@strapi/design-system';
import { Minus, Plus } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { styled } from 'styled-components';
import { useField, useForm } from '../../../../../components/Form';
import { StringInput } from '../../../../../components/FormInputs/String';
const AddHeaderButton = styled(TextButton)`
cursor: pointer;
`;
/* -------------------------------------------------------------------------------------------------
* HeadersInput
* -----------------------------------------------------------------------------------------------*/
@ -44,7 +49,7 @@ const HeadersInput = () => {
<Box padding={8} background="neutral100" hasRadius>
{value.map((_, index) => {
return (
<Grid.Root key={index} gap={4}>
<Grid.Root key={index} gap={4} padding={2}>
<Grid.Item col={6}>
<HeaderCombobox
name={`headers.${index}.key`}
@ -92,23 +97,23 @@ const HeadersInput = () => {
</Flex>
</Flex>
</Grid.Item>
<Grid.Item col={12}>
<TextButton
type="button"
onClick={() => {
addFieldRow('headers', { key: '', value: '' });
}}
startIcon={<Plus />}
>
{formatMessage({
id: 'Settings.webhooks.create.header',
defaultMessage: 'Create new header',
})}
</TextButton>
</Grid.Item>
</Grid.Root>
);
})}
<Box paddingTop={4}>
<AddHeaderButton
type="button"
onClick={() => {
addFieldRow('headers', { key: '', value: '' });
}}
startIcon={<Plus />}
>
{formatMessage({
id: 'Settings.webhooks.create.header',
defaultMessage: 'Create new header',
})}
</AddHeaderButton>
</Box>
</Box>
</Flex>
);

Some files were not shown because too many files have changed in this diff Show More