Merge branch 'release-ccb' into hotfix-task4228

This commit is contained in:
SAITO-PC-3\saito.k 2024-06-13 16:45:30 +09:00
commit b997b928b8
249 changed files with 54605 additions and 1427 deletions

View File

@ -0,0 +1,312 @@
# Pipeline側でKeyVaultやDocker、AppService等に対する操作権限を持ったServiceConenctionを作成しておくこと
# また、環境変数 STATIC_DICTATION_DEPLOYMENT_TOKEN の値として静的WebAppsのデプロイトークンを設定しておくこと
trigger:
branches:
include:
- release-ccb
tags:
include:
- stage-*
jobs:
- job: initialize
displayName: Initialize
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
persistCredentials: true
- script: |
git fetch origin release-ccb:release-ccb
if git merge-base --is-ancestor $(Build.SourceVersion) release-ccb; then
echo "This commit is in the release-ccb branch."
else
echo "This commit is not in the release-ccb branch."
exit 1
fi
displayName: 'タグが付けられたCommitがrelease-ccbブランチに存在するか確認'
- job: backend_test
dependsOn: initialize
condition: succeeded('initialize')
displayName: UnitTest
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Bash@3
displayName: Bash Script (Test)
inputs:
targetType: inline
workingDirectory: dictation_server/.devcontainer
script: |
docker-compose -f pipeline-docker-compose.yml build
docker-compose -f pipeline-docker-compose.yml up -d
docker-compose exec -T dictation_server sudo npm ci
docker-compose exec -T dictation_server sudo npm run migrate:up:test
docker-compose exec -T dictation_server sudo npm run test
- job: backend_build
dependsOn: backend_test
condition: succeeded('backend_test')
displayName: Build And Push Backend Image
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Npm@1
displayName: npm ci
inputs:
command: ci
workingDir: dictation_server
verbose: false
- task: Docker@0
displayName: build
inputs:
azureSubscriptionEndpoint: 'omds-service-connection-stg'
azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}'
dockerFile: DockerfileServerDictation.dockerfile
imageName: odmscloud/staging/dictation:$(Build.SourceVersion)
buildArguments: |
BUILD_VERSION=$(Build.SourceVersion)
- task: Docker@0
displayName: push
inputs:
azureSubscriptionEndpoint: 'omds-service-connection-stg'
azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}'
action: Push an image
imageName: odmscloud/staging/dictation:$(Build.SourceVersion)
- job: frontend_build_staging
dependsOn: backend_build
condition: succeeded('backend_build')
displayName: Build Frontend Files(staging)
variables:
storageAccountName: saomdspipeline
environment: staging
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Npm@1
displayName: npm ci
inputs:
command: ci
workingDir: dictation_client
verbose: false
- task: Bash@3
displayName: Bash Script
inputs:
targetType: inline
script: cd dictation_client && npm run build:stg
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: dictation_client/build
includeRootFolder: false
archiveType: 'zip'
archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip'
replaceExistingArchive: true
- task: AzureCLI@2
inputs:
azureSubscription: 'omds-service-connection-stg'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az storage blob upload \
--auth-mode login \
--account-name $(storageAccountName) \
--container-name $(environment) \
--name $(Build.SourceVersion).zip \
--type block \
--overwrite \
--file $(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip
- job: function_test
dependsOn: frontend_build_staging
condition: succeeded('frontend_build_staging')
displayName: UnitTest
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Bash@3
displayName: Bash Script (Test)
inputs:
targetType: inline
workingDirectory: dictation_function/.devcontainer
script: |
docker-compose -f pipeline-docker-compose.yml build
docker-compose -f pipeline-docker-compose.yml up -d
docker-compose exec -T dictation_function sudo npm ci
docker-compose exec -T dictation_function sudo npm run test
- job: function_build
dependsOn: function_test
condition: succeeded('function_test')
displayName: Build And Push Function Image
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Npm@1
displayName: npm ci
inputs:
command: ci
workingDir: dictation_function
verbose: false
- task: Docker@0
displayName: build
inputs:
azureSubscriptionEndpoint: 'omds-service-connection-stg'
azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}'
dockerFile: DockerfileFunctionDictation.dockerfile
imageName: odmscloud/staging/dictation_function:$(Build.SourceVersion)
buildArguments: |
BUILD_VERSION=$(Build.SourceVersion)
- task: Docker@0
displayName: push
inputs:
azureSubscriptionEndpoint: 'omds-service-connection-stg'
azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}'
action: Push an image
imageName: odmscloud/staging/dictation_function:$(Build.SourceVersion)
- job: backend_deploy
dependsOn: function_build
condition: succeeded('function_build')
displayName: Backend Deploy
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: AzureWebAppContainer@1
inputs:
azureSubscription: 'omds-service-connection-stg'
appName: 'app-odms-dictation-stg'
deployToSlotOrASE: true
resourceGroupName: 'stg-application-rg'
slotName: 'staging'
containers: 'crodmsregistrymaintenance.azurecr.io/odmscloud/staging/dictation:$(Build.SourceVersion)'
- job: frontend_deploy
dependsOn: backend_deploy
condition: succeeded('backend_deploy')
displayName: Deploy Frontend Files
variables:
storageAccountName: saomdspipeline
environment: staging
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: AzureCLI@2
inputs:
azureSubscription: 'omds-service-connection-stg'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az storage blob download \
--auth-mode login \
--account-name $(storageAccountName) \
--container-name $(environment) \
--name $(Build.SourceVersion).zip \
--file $(Build.SourcesDirectory)/$(Build.SourceVersion).zip
- task: Bash@3
displayName: Bash Script
inputs:
targetType: inline
script: unzip $(Build.SourcesDirectory)/$(Build.SourceVersion).zip -d $(Build.SourcesDirectory)/$(Build.SourceVersion)
- task: AzureStaticWebApp@0
displayName: 'Static Web App: '
inputs:
workingDirectory: '$(Build.SourcesDirectory)'
app_location: '/$(Build.SourceVersion)'
config_file_location: /dictation_client
skip_app_build: true
skip_api_build: true
is_static_export: false
verbose: false
azure_static_web_apps_api_token: $(STATIC_DICTATION_DEPLOYMENT_TOKEN)
- job: function_deploy
dependsOn: frontend_deploy
condition: succeeded('frontend_deploy')
displayName: Function Deploy
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: AzureFunctionAppContainer@1
inputs:
azureSubscription: 'omds-service-connection-stg'
appName: 'func-odms-dictation-stg'
imageName: 'crodmsregistrymaintenance.azurecr.io/odmscloud/staging/dictation_function:$(Build.SourceVersion)'
- job: smoke_test
dependsOn: function_deploy
condition: succeeded('function_deploy')
displayName: 'smoke test'
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
# スモークテスト用にjobを確保
- job: swap_slot
dependsOn: smoke_test
condition: succeeded('smoke_test')
displayName: 'Swap Staging and Production'
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: AzureAppServiceManage@0
displayName: 'Azure App Service Manage: app-odms-dictation-stg'
inputs:
azureSubscription: 'omds-service-connection-stg'
action: 'Swap Slots'
WebAppName: 'app-odms-dictation-stg'
ResourceGroupName: 'stg-application-rg'
SourceSlot: 'staging'
SwapWithProduction: true
- job: migration
dependsOn: swap_slot
condition: succeeded('swap_slot')
displayName: DB migration
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: AzureKeyVault@2
displayName: 'Azure Key Vault: kv-odms-secret-stg'
inputs:
ConnectedServiceName: 'omds-service-connection-stg'
KeyVaultName: kv-odms-secret-stg
- task: CmdLine@2
displayName: migration
inputs:
script: >2
# DB接続情報書き換え
sed -i -e "s/DB_NAME_CCB/$(db-name-ccb)/g" ./dictation_server/db/dbconfig.yml
sed -i -e "s/DB_PASS/$(admin-db-pass)/g" ./dictation_server/db/dbconfig.yml
sed -i -e "s/DB_USERNAME/$(admin-db-user)/g" ./dictation_server/db/dbconfig.yml
sed -i -e "s/DB_PORT/$(db-port)/g" ./dictation_server/db/dbconfig.yml
sed -i -e "s/DB_HOST/$(db-host)/g" ./dictation_server/db/dbconfig.yml
sql-migrate --version
cat ./dictation_server/db/dbconfig.yml
# migration実行
sql-migrate up -config=./dictation_server/db/dbconfig.yml -env=ci_ccb

View File

@ -170,9 +170,29 @@ jobs:
--type block \
--overwrite \
--file $(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip
- job: function_build
- job: function_test
dependsOn: frontend_build_production
condition: succeeded('frontend_build_production')
displayName: UnitTest
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Bash@3
displayName: Bash Script (Test)
inputs:
targetType: inline
workingDirectory: dictation_function/.devcontainer
script: |
docker-compose -f pipeline-docker-compose.yml build
docker-compose -f pipeline-docker-compose.yml up -d
docker-compose exec -T dictation_function sudo npm ci
docker-compose exec -T dictation_function sudo npm run test
- job: function_build
dependsOn: function_test
condition: succeeded('function_test')
displayName: Build And Push Function Image
pool:
name: odms-deploy-pipeline
@ -186,32 +206,6 @@ jobs:
command: ci
workingDir: dictation_function
verbose: false
- task: AzureKeyVault@2
displayName: 'Azure Key Vault: kv-odms-secret-stg'
inputs:
ConnectedServiceName: 'omds-service-connection-stg'
KeyVaultName: kv-odms-secret-stg
SecretsFilter: '*'
- task: Bash@3
displayName: Bash Script (Test)
inputs:
targetType: inline
script: |
cd dictation_function
npm run test
env:
TENANT_NAME: xxxxxxxxxxxx
SIGNIN_FLOW_NAME: xxxxxxxxxxxx
ADB2C_TENANT_ID: $(adb2c-tenant-id)
ADB2C_CLIENT_ID: $(adb2c-client-id)
ADB2C_CLIENT_SECRET: $(adb2c-client-secret)
ADB2C_ORIGIN: xxxxxx
SENDGRID_API_KEY: $(sendgrid-api-key)
MAIL_FROM: xxxxxx
APP_DOMAIN: http://localhost:8081/
REDIS_HOST: xxxxxxxxxxxx
REDIS_PORT: 0
REDIS_PASSWORD: xxxxxxxxxxxx
- task: Docker@0
displayName: build
inputs:

View File

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,8 @@
"typecheck": "tsc --noEmit",
"codegen": "sh codegen.sh",
"lint": "eslint --cache . --ext .js,.ts,.tsx",
"lint:fix": "npm run lint -- --fix"
"lint:fix": "npm run lint -- --fix",
"test": "jest"
},
"dependencies": {
"@azure/msal-browser": "^2.33.0",
@ -25,7 +26,6 @@
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^14.2.1",
"@types/jest": "^27.5.2",
"@types/node": "^17.0.45",
"@types/react": "^18.0.14",
"@types/react-dom": "^18.0.6",
@ -38,6 +38,7 @@
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"luxon": "^3.3.0",
"papaparse": "^5.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-google-recaptcha-v3": "^1.10.0",
@ -56,8 +57,10 @@
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@mdx-js/react": "^2.1.2",
"@openapitools/openapi-generator-cli": "^2.5.2",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.14.191",
"@types/luxon": "^3.2.0",
"@types/papaparse": "^5.3.14",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/redux-mock-store": "^1.0.3",
@ -67,16 +70,18 @@
"babel-loader": "^8.2.5",
"eslint": "^8.19.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.5.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"jest": "^29.7.0",
"license-checker": "^25.0.1",
"prettier": "^2.7.1",
"prettier": "^2.8.8",
"redux-mock-store": "^1.5.4",
"sass": "^1.58.3",
"ts-jest": "^29.1.2",
"typescript": "^4.7.4",
"vite": "^4.1.4",
"vite-plugin-env-compatible": "^1.1.1",
@ -99,4 +104,4 @@
}
]
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{fill:#282828;}
</style>
<path class="st0" d="M24.1,38l5.7-5.7l-5.7-5.6L22,28.8l2.1,2.1c-0.9,0-1.8-0.1-2.7-0.4c-0.9-0.3-1.7-0.9-2.4-1.6
c-0.7-0.7-1.2-1.4-1.5-2.3C17.2,25.8,17,24.9,17,24c0-0.6,0.1-1.1,0.2-1.7s0.4-1.1,0.6-1.7l-2.2-2.2c-0.6,0.8-1,1.7-1.2,2.6
C14.1,22.1,14,23,14,24c0,1.3,0.2,2.5,0.8,3.8C15.2,29,16,30.1,17,31s2,1.7,3.2,2.2c1.2,0.5,2.4,0.7,3.7,0.8L22,35.9L24.1,38z
M32.4,29.5c0.6-0.8,1-1.7,1.2-2.6C33.9,25.9,34,25,34,24c0-1.3-0.2-2.5-0.7-3.8s-1.2-2.4-2.2-3.3s-2.1-1.7-3.3-2.2
c-1.2-0.5-2.5-0.7-3.7-0.7l1.9-1.9L23.9,10l-5.7,5.7l5.7,5.6l2.1-2.1L23.8,17c0.9,0,1.8,0.2,2.8,0.5s1.7,0.9,2.4,1.5
s1.2,1.4,1.5,2.3c0.4,0.9,0.5,1.7,0.5,2.6c0,0.6-0.1,1.1-0.2,1.7c-0.1,0.6-0.4,1.1-0.6,1.6L32.4,29.5z M24,44
c-2.7,0-5.3-0.5-7.8-1.6s-4.6-2.5-6.4-4.3s-3.2-3.9-4.3-6.4S4,26.7,4,24c0-2.8,0.5-5.4,1.6-7.8s2.5-4.5,4.3-6.3s3.9-3.2,6.4-4.3
S21.3,4,24,4c2.8,0,5.4,0.5,7.8,1.6s4.6,2.5,6.4,4.3s3.2,3.9,4.3,6.3c1.1,2.4,1.6,5,1.6,7.8c0,2.7-0.5,5.3-1.6,7.8
c-1,2.4-2.5,4.6-4.3,6.4s-3.9,3.2-6.4,4.3S26.8,44,24,44z M24,41c4.7,0,8.8-1.7,12-5c3.3-3.3,5-7.3,5-12c0-4.7-1.6-8.8-5-12.1
c-3.3-3.3-7.3-5-12-5c-4.7,0-8.7,1.7-12,5S7,19.3,7,24c0,4.7,1.7,8.7,5,12C15.3,39.3,19.3,41,24,41z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="_レイヤー_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 37.17"><defs><style>.cls-1{fill:#282828;}.cls-1,.cls-2{stroke-width:0px;}.cls-2{fill:#e6e6e6;}</style></defs><path class="cls-2" d="M42.13,35.07l-2.15,2.1L3,5.1v6.1H0V0h11.15v3h-6l36.98,32.07Z"/><path class="cls-1" d="M39.98,37.17l-2.1-2.15L74.9,3h-6.1V0h11.2v11.15h-3v-6l-37.03,32.02Z"/></svg>

After

Width:  |  Height:  |  Size: 409 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M450-313v-371L330-564l-43-43 193-193 193 193-43 43-120-120v371h-60ZM220-160q-24 0-42-18t-18-42v-143h60v143h520v-143h60v143q0 24-18 42t-42 18H220Z"/></svg>

After

Width:  |  Height:  |  Size: 251 B

View File

@ -63,4 +63,26 @@ export const errorCodes = [
"E011004", // ワークタイプ使用中エラー
"E013001", // ワークフローのAuthorIDとWorktypeIDのペア重複エラー
"E013002", // ワークフロー不在エラー
"E014001", // ユーザー削除エラー(削除しようとしたユーザーがすでに削除済みだった)
"E014002", // ユーザー削除エラー(削除しようとしたユーザーが管理者だった)
"E014003", // ユーザー削除エラー削除しようとしたAuthorのAuthorIDがWorkflowに指定されていた
"E014004", // ユーザー削除エラー削除しようとしたTypistがWorkflowのTypist候補として指定されていた
"E014005", // ユーザー削除エラー削除しようとしたTypistがUserGroupに所属していた
"E014006", // ユーザー削除エラー(削除しようとしたユーザが所有者の未完了のタスクが残っている)
"E014007", // ユーザー削除エラー(削除しようとしたユーザーが有効なライセンスを持っていた)
"E014009", // ユーザー削除エラー削除しようとしたTypistが未完了のタスクのルーティングに設定されている
"E015001", // タイピストグループ削除済みエラー
"E015002", // タイピストグループがワークフローに紐づいているエラー
"E015003", // タイピストグループがルーティングされているエラー
"E016001", // テンプレートファイル削除エラー(削除しようとしたテンプレートファイルがすでに削除済みだった)
"E016002", // テンプレートファイル削除エラー削除しようとしたテンプレートファイルがWorkflowに指定されていた
"E016003", // テンプレートファイル削除エラー(削除しようとしたテンプレートファイルが未完了のタスクに紐づいていた)
"E017001", // 親アカウント変更不可エラー(指定したアカウントが存在しない)
"E017002", // 親アカウント変更不可エラー(階層関係が不正)
"E017003", // 親アカウント変更不可エラー(リージョンが同一でない)
"E018001", // パートナーアカウント削除エラー(削除条件を満たしていない)
"E019001", // パートナーアカウント取得不可エラー(階層構造が不正)
"E020001", // パートナーアカウント変更エラー(変更条件を満たしていない)
"E021001", // 音声ファイル名変更不可エラー(権限不足)
"E021002", // 音声ファイル名変更不可エラー(同名ファイルが存在)
] as const;

View File

@ -6,4 +6,4 @@ export type ErrorObject = {
statusCode?: number;
};
export type ErrorCodeType = typeof errorCodes[number];
export type ErrorCodeType = (typeof errorCodes)[number];

View File

@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/naming-convention */
// Jestによるparser.tsのテスト
import fs from "fs";
import { CSVType, parseCSV } from "./parser";
describe("parse", () => {
it("指定形式のCSV文字列をパースできる", async () => {
const text = fs.readFileSync("src/common/test/test_001.csv", "utf-8");
const actualData = await parseCSV(text);
const expectData: CSVType[] = [
{
name: "hoge",
email: "sample@example.com",
role: 1,
author_id: "HOGE",
auto_assign: 1,
notification: 1,
encryption: 1,
encryption_password: "abcd",
prompt: 0,
},
];
expect(actualData).toEqual(expectData);
});
it("指定形式のヘッダでない場合、例外が送出される | author_id(値がoptionial)がない", async () => {
const text = fs.readFileSync("src/common/test/test_002.csv", "utf-8");
try {
await parseCSV(text);
fail("例外が発生しませんでした");
} catch (e) {
expect(e).toEqual(new Error("Invalid CSV format"));
}
});
it("指定形式のヘッダでない場合、例外が送出される | email(値が必須)がない", async () => {
const text = fs.readFileSync("src/common/test/test_003.csv", "utf-8");
try {
await parseCSV(text);
fail("例外が発生しませんでした");
} catch (e) {
expect(e).toEqual(new Error("Invalid CSV format"));
}
});
it("指定形式のヘッダでない場合、例外が送出される | emailがスペルミス", async () => {
const text = fs.readFileSync("src/common/test/test_004.csv", "utf-8");
try {
await parseCSV(text);
fail("例外が発生しませんでした");
} catch (e) {
expect(e).toEqual(new Error("Invalid CSV format"));
}
});
it("指定形式のCSV文字列をパースできる | 抜けているパラメータ(文字列)はnullとなる", async () => {
const text = fs.readFileSync("src/common/test/test_005.csv", "utf-8");
const actualData = await parseCSV(text);
const expectData: CSVType[] = [
{
name: "hoge",
email: "sample@example.com",
role: 1,
author_id: null,
auto_assign: 1,
notification: 1,
encryption: 1,
encryption_password: "abcd",
prompt: 0,
},
];
expect(actualData).toEqual(expectData);
});
it("指定形式のCSV文字列をパースできる | 抜けているパラメータ(数値)はnullとなる", async () => {
const text = fs.readFileSync("src/common/test/test_006.csv", "utf-8");
const actualData = await parseCSV(text);
const expectData: CSVType[] = [
{
name: "hoge",
email: "sample@example.com",
role: null,
author_id: "HOGE",
auto_assign: 1,
notification: 1,
encryption: 1,
encryption_password: "abcd",
prompt: 0,
},
];
expect(actualData).toEqual(expectData);
});
it("指定形式のCSV文字列をパースできる | 余計なパラメータがあっても問題はない", async () => {
const text = fs.readFileSync("src/common/test/test_007.csv", "utf-8");
const actualData = await parseCSV(text);
const expectData: CSVType[] = [
{
name: "hoge",
email: "sample@example.com",
role: 1,
author_id: "HOGE",
auto_assign: 1,
notification: 1,
encryption: 1,
encryption_password: "abcd",
prompt: 0,
},
{
name: "hoge2",
email: "sample2@example.com",
role: 1,
author_id: "HOGE2",
auto_assign: 1,
notification: 1,
encryption: 1,
encryption_password: "abcd2",
prompt: 0,
},
];
expect(actualData.length).toBe(expectData.length);
// 余計なパラメータ格納用に __parsed_extra: string[] というプロパティが作られてしまうので、既知のプロパティ毎に比較
for (let i = 0; i < actualData.length; i += 1) {
const actualValue = actualData[i];
const expectValue = expectData[i];
expect(actualValue.author_id).toEqual(expectValue.author_id);
expect(actualValue.auto_assign).toEqual(expectValue.auto_assign);
expect(actualValue.email).toEqual(expectValue.email);
expect(actualValue.encryption).toEqual(expectValue.encryption);
expect(actualValue.encryption_password).toEqual(
expectValue.encryption_password
);
expect(actualValue.name).toEqual(expectValue.name);
expect(actualValue.notification).toEqual(expectValue.notification);
expect(actualValue.prompt).toEqual(expectValue.prompt);
expect(actualValue.role).toEqual(expectValue.role);
}
});
it("author_id,encryption_passwordが数値のみの場合でも、文字列として変換できる", async () => {
const text = fs.readFileSync("src/common/test/test_008.csv", "utf-8");
const actualData = await parseCSV(text);
const expectData: CSVType[] = [
{
name: "hoge",
email: "sample@example.com",
role: 1,
author_id: "1111",
auto_assign: 1,
notification: 1,
encryption: 1,
encryption_password: "222222",
prompt: 0,
},
];
expect(actualData).toEqual(expectData);
});
});

View File

@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/naming-convention */
import Papa, { ParseResult } from "papaparse";
export type CSVType = {
name: string | null;
email: string | null;
role: number | null;
author_id: string | null;
auto_assign: number | null;
notification: number;
encryption: number | null;
encryption_password: string | null;
prompt: number | null;
};
// CSVTypeのプロパティ名を文字列の配列で定義する
const CSVTypeFields: (keyof CSVType)[] = [
"name",
"email",
"role",
"author_id",
"auto_assign",
"notification",
"encryption",
"encryption_password",
"prompt",
];
// 2つの配列が等しいかどうかを判定する
const equals = (lhs: string[], rhs: string[]) => {
if (lhs.length !== rhs.length) return false;
for (let i = 0; i < lhs.length; i += 1) {
if (lhs[i] !== rhs[i]) return false;
}
return true;
};
/** CSVファイルをCSVType型に変換するパーサー */
export const parseCSV = async (csvString: string): Promise<CSVType[]> =>
new Promise((resolve, reject) => {
Papa.parse<CSVType>(csvString, {
download: false,
worker: false, // XXX: workerを使うとエラーが発生するためfalseに設定
header: true,
dynamicTyping: {
// author_id, encryption_passwordは数値のみの場合、numberに変換されたくないためdynamicTypingをtrueにしない
role: true,
auto_assign: true,
notification: true,
encryption: true,
prompt: true,
},
// dynamicTypingがfalseの場合、空文字をnullに変換できないためtransformを使用する
transform: (value, field) => {
if (field === "author_id" || field === "encryption_password") {
// 空文字の場合はnullに変換する
if (value === "") {
return null;
}
}
return value;
},
complete: (results: ParseResult<CSVType>) => {
// ヘッダーがCSVTypeFieldsと一致しない場合はエラーを返す
if (!equals(results.meta.fields ?? [], CSVTypeFields)) {
reject(new Error("Invalid CSV format"));
}
resolve(results.data);
},
error: (error: Error) => {
reject(error);
},
});
});

View File

@ -0,0 +1,2 @@
name,email,role,author_id,auto_assign,notification,encryption,encryption_password,prompt
hoge,sample@example.com,1,"HOGE",1,1,1,abcd,0
1 name email role author_id auto_assign notification encryption encryption_password prompt
2 hoge sample@example.com 1 HOGE 1 1 1 abcd 0

View File

@ -0,0 +1,2 @@
name,email,role,auto_assign,notification,encryption,encryption_password,prompt
hoge,sample@example.com,1,"HOGE",1,1,1,abcd,0
Can't render this file because it has a wrong number of fields in line 2.

View File

@ -0,0 +1,2 @@
name,role,author_id,auto_assign,notification,encryption,encryption_password,prompt
hoge,sample@example.com,1,"HOGE",1,1,1,abcd,0
Can't render this file because it has a wrong number of fields in line 2.

View File

@ -0,0 +1,2 @@
name,emeil,role,author_id,auto_assign,notification,encryption,encryption_password,prompt
hoge,sample@example.com,1,"HOGE",1,1,1,abcd,0
1 name emeil role author_id auto_assign notification encryption encryption_password prompt
2 hoge sample@example.com 1 HOGE 1 1 1 abcd 0

View File

@ -0,0 +1,2 @@
name,email,role,author_id,auto_assign,notification,encryption,encryption_password,prompt
hoge,sample@example.com,1,,1,1,1,abcd,0
1 name email role author_id auto_assign notification encryption encryption_password prompt
2 hoge sample@example.com 1 1 1 1 abcd 0

View File

@ -0,0 +1,2 @@
name,email,role,author_id,auto_assign,notification,encryption,encryption_password,prompt
hoge,sample@example.com,,"HOGE",1,1,1,abcd,0
1 name email role author_id auto_assign notification encryption encryption_password prompt
2 hoge sample@example.com HOGE 1 1 1 abcd 0

View File

@ -0,0 +1,3 @@
name,email,role,author_id,auto_assign,notification,encryption,encryption_password,prompt
hoge,sample@example.com,1,"HOGE",1,1,1,abcd,0,x
hoge2,sample2@example.com,1,"HOGE2",1,1,1,abcd2,0,1,32,4,aa
Can't render this file because it has a wrong number of fields in line 2.

View File

@ -0,0 +1,2 @@
name,email,role,author_id,auto_assign,notification,encryption,encryption_password,prompt
hoge,sample@example.com,1,1111,1,1,1,222222,0
1 name email role author_id auto_assign notification encryption encryption_password prompt
2 hoge sample@example.com 1 1111 1 1 1 222222 0

View File

@ -4,6 +4,7 @@ import {
updateAccountInfoAsync,
getAccountRelationsAsync,
deleteAccountAsync,
updateFileDeleteSettingAsync,
} from "./operations";
const initialState: AccountState = {
@ -15,6 +16,8 @@ const initialState: AccountState = {
tier: 0,
country: "",
delegationPermission: false,
autoFileDelete: false,
fileRetentionDays: 0,
},
},
dealers: [],
@ -29,6 +32,8 @@ const initialState: AccountState = {
secondryAdminUserId: undefined,
},
isLoading: false,
autoFileDelete: false,
fileRetentionDays: 0,
},
};
@ -64,6 +69,20 @@ export const accountSlice = createSlice({
const { secondryAdminUserId } = action.payload;
state.apps.updateAccountInfo.secondryAdminUserId = secondryAdminUserId;
},
changeAutoFileDelete: (
state,
action: PayloadAction<{ autoFileDelete: boolean }>
) => {
const { autoFileDelete } = action.payload;
state.apps.autoFileDelete = autoFileDelete;
},
changeFileRetentionDays: (
state,
action: PayloadAction<{ fileRetentionDays: number }>
) => {
const { fileRetentionDays } = action.payload;
state.apps.fileRetentionDays = fileRetentionDays;
},
cleanupApps: (state) => {
state.domain = initialState.domain;
},
@ -85,6 +104,10 @@ export const accountSlice = createSlice({
action.payload.accountInfo.account.primaryAdminUserId;
state.apps.updateAccountInfo.secondryAdminUserId =
action.payload.accountInfo.account.secondryAdminUserId;
state.apps.autoFileDelete =
action.payload.accountInfo.account.autoFileDelete;
state.apps.fileRetentionDays =
action.payload.accountInfo.account.fileRetentionDays;
state.apps.isLoading = false;
});
builder.addCase(getAccountRelationsAsync.rejected, (state) => {
@ -99,6 +122,15 @@ export const accountSlice = createSlice({
builder.addCase(updateAccountInfoAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(updateFileDeleteSettingAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(updateFileDeleteSettingAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(updateFileDeleteSettingAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(deleteAccountAsync.pending, (state) => {
state.apps.isLoading = true;
});
@ -115,6 +147,8 @@ export const {
changeDealerPermission,
changePrimaryAdministrator,
changeSecondryAdministrator,
changeAutoFileDelete,
changeFileRetentionDays,
cleanupApps,
} = accountSlice.actions;
export default accountSlice.reducer;

View File

@ -9,6 +9,7 @@ import {
UpdateAccountInfoRequest,
UsersApi,
DeleteAccountRequest,
UpdateFileDeleteSettingRequest,
} from "../../api/api";
import { Configuration } from "../../api/configuration";
import { ViewAccountRelationsInfo } from "./types";
@ -112,6 +113,58 @@ export const updateAccountInfoAsync = createAsyncThunk<
}
});
export const updateFileDeleteSettingAsync = createAsyncThunk<
{
/* Empty Object */
},
{ autoFileDelete: boolean; fileRetentionDays: number },
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("accounts/updateFileDeleteSettingAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const accountApi = new AccountsApi(config);
const requestParam: UpdateFileDeleteSettingRequest = {
autoFileDelete: args.autoFileDelete,
retentionDays: args.fileRetentionDays,
};
try {
await accountApi.updateFileDeleteSetting(requestParam, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
const error = createErrorObject(e);
const errorMessage = getTranslationID("common.message.internalServerError");
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const deleteAccountAsync = createAsyncThunk<
{
/* Empty Object */

View File

@ -16,3 +16,18 @@ export const selectIsLoading = (state: RootState) =>
state.account.apps.isLoading;
export const selectUpdateAccountInfo = (state: RootState) =>
state.account.apps.updateAccountInfo;
export const selectFileDeleteSetting = (state: RootState) => {
const { autoFileDelete, fileRetentionDays } = state.account.apps;
return {
autoFileDelete,
fileRetentionDays,
};
};
export const selectInputValidationErrors = (state: RootState) => {
const { fileRetentionDays } = state.account.apps;
const hasFileRetentionDaysError =
fileRetentionDays <= 0 || fileRetentionDays >= 1000;
return {
hasFileRetentionDaysError,
};
};

View File

@ -19,4 +19,6 @@ export interface Domain {
export interface Apps {
updateAccountInfo: UpdateAccountInfoRequest;
isLoading: boolean;
autoFileDelete: boolean;
fileRetentionDays: number;
}

View File

@ -6,7 +6,7 @@ export const STATUS = {
BACKUP: "Backup",
} as const;
export type StatusType = typeof STATUS[keyof typeof STATUS];
export type StatusType = (typeof STATUS)[keyof typeof STATUS];
export const LIMIT_TASK_NUM = 100;
@ -26,7 +26,7 @@ export const SORTABLE_COLUMN = {
TranscriptionFinishedDate: "TRANSCRIPTION_FINISHED_DATE",
} as const;
export type SortableColumnType =
typeof SORTABLE_COLUMN[keyof typeof SORTABLE_COLUMN];
(typeof SORTABLE_COLUMN)[keyof typeof SORTABLE_COLUMN];
export const isSortableColumnType = (
value: string
@ -36,14 +36,14 @@ export const isSortableColumnType = (
};
export type SortableColumnList =
typeof SORTABLE_COLUMN[keyof typeof SORTABLE_COLUMN];
(typeof SORTABLE_COLUMN)[keyof typeof SORTABLE_COLUMN];
export const DIRECTION = {
ASC: "ASC",
DESC: "DESC",
} as const;
export type DirectionType = typeof DIRECTION[keyof typeof DIRECTION];
export type DirectionType = (typeof DIRECTION)[keyof typeof DIRECTION];
// DirectionTypeの型チェック関数
export const isDirectionType = (arg: string): arg is DirectionType =>

View File

@ -11,6 +11,8 @@ import {
playbackAsync,
updateAssigneeAsync,
cancelAsync,
deleteTaskAsync,
renameFileAsync,
} from "./operations";
import {
SORTABLE_COLUMN,
@ -218,6 +220,25 @@ export const dictationSlice = createSlice({
builder.addCase(backupTasksAsync.rejected, (state) => {
state.apps.isDownloading = false;
});
builder.addCase(deleteTaskAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(deleteTaskAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(deleteTaskAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(renameFileAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(renameFileAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(renameFileAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});

View File

@ -565,10 +565,21 @@ export const backupTasksAsync = createAsyncThunk<
a.click();
a.parentNode?.removeChild(a);
// eslint-disable-next-line no-await-in-loop
await tasksApi.backup(task.audioFileId, {
headers: { authorization: `Bearer ${accessToken}` },
});
// バックアップ済みに更新
try {
// eslint-disable-next-line no-await-in-loop
await tasksApi.backup(task.audioFileId, {
headers: { authorization: `Bearer ${accessToken}` },
});
} catch (e) {
// e ⇒ errorObjectに変換
const error = createErrorObject(e);
if (error.code === "E010603") {
// タスクが削除済みの場合は成功扱いとする
} else {
throw e;
}
}
}
}
@ -580,8 +591,22 @@ export const backupTasksAsync = createAsyncThunk<
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
// e ⇒ errorObjectに変換
const error = createErrorObject(e);
if (error.code === "E010603") {
// 存在しない音声ファイルをダウンロードしようとした場合
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID(
"dictationPage.message.fileAlreadyDeletedError"
),
})
);
return thunkApi.rejectWithValue({ error });
}
thunkApi.dispatch(
openSnackbar({
level: "error",
@ -592,3 +617,143 @@ export const backupTasksAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const deleteTaskAsync = createAsyncThunk<
{
// empty
},
{
// パラメータ
audioFileId: number;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/deleteTaskAsync", async (args, thunkApi) => {
const { audioFileId } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const tasksApi = new TasksApi(config);
try {
await tasksApi.deleteTask(audioFileId, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
let message = getTranslationID("common.message.internalServerError");
if (error.statusCode === 400) {
if (error.code === "E010603") {
// タスクが削除済みの場合は成功扱いとする
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
}
if (error.code === "E010601") {
// タスクがInprogressの場合はエラー
message = getTranslationID("dictationPage.message.deleteFailedError");
}
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message,
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const renameFileAsync = createAsyncThunk<
{
// empty
},
{
// パラメータ
audioFileId: number;
fileName: string;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/renameFileAsync", async (args, thunkApi) => {
const { audioFileId, fileName } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const filesApi = new FilesApi(config);
try {
await filesApi.fileRename(
{ fileName, audioFileId },
{ headers: { authorization: `Bearer ${accessToken}` } }
);
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
let message = getTranslationID("common.message.internalServerError");
// 変更権限がない場合はエラー
if (error.code === "E021001") {
message = getTranslationID("dictationPage.message.fileRenameFailedError");
}
// ファイル名が既に存在する場合はエラー
if (error.code === "E021002") {
message = getTranslationID(
"dictationPage.message.fileNameAleadyExistsError"
);
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message,
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -1,5 +1,6 @@
import { createSlice } from "@reduxjs/toolkit";
import { LicenseCardActivateState } from "./state";
import { activateCardLicenseAsync } from "./operations";
const initialState: LicenseCardActivateState = {
apps: {
@ -14,6 +15,17 @@ export const licenseCardActivateSlice = createSlice({
state.apps = initialState.apps;
},
},
extraReducers: (builder) => {
builder.addCase(activateCardLicenseAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(activateCardLicenseAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(activateCardLicenseAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});
export const { cleanupApps } = licenseCardActivateSlice.actions;

View File

@ -1,6 +1,10 @@
import { createSlice } from "@reduxjs/toolkit";
import { LicenseSummaryState } from "./state";
import { getCompanyNameAsync, getLicenseSummaryAsync } from "./operations";
import {
getCompanyNameAsync,
getLicenseSummaryAsync,
updateRestrictionStatusAsync,
} from "./operations";
const initialState: LicenseSummaryState = {
domain: {
@ -35,12 +39,30 @@ export const licenseSummarySlice = createSlice({
},
},
extraReducers: (builder) => {
builder.addCase(getLicenseSummaryAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(getLicenseSummaryAsync.fulfilled, (state, action) => {
state.domain.licenseSummaryInfo = action.payload;
state.apps.isLoading = false;
});
builder.addCase(getLicenseSummaryAsync.rejected, (state) => {
state.apps.isLoading = false;
});
// 画面側ではgetLicenseSummaryAsyncと並行して呼び出されているため、レーシングを考慮してこちらではisLoadingを更新しない
// 本来は両方の完了を待ってからisLoadingを更新するべきだが、現時点ではスピード重視のためケアしない。
builder.addCase(getCompanyNameAsync.fulfilled, (state, action) => {
state.domain.accountInfo.companyName = action.payload.companyName;
});
builder.addCase(updateRestrictionStatusAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(updateRestrictionStatusAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(updateRestrictionStatusAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});

View File

@ -8,6 +8,7 @@ import {
GetCompanyNameResponse,
GetLicenseSummaryResponse,
PartnerLicenseInfo,
UpdateRestrictionStatusRequest,
} from "../../../api/api";
import { Configuration } from "../../../api/configuration";
import { ErrorObject, createErrorObject } from "../../../common/errors";
@ -123,3 +124,58 @@ export const getCompanyNameAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const updateRestrictionStatusAsync = createAsyncThunk<
{
/* Empty Object */
},
{
accountId: number;
restricted: boolean;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("accounts/updateRestrictionStatusAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const accountApi = new AccountsApi(config);
const requestParam: UpdateRestrictionStatusRequest = {
accountId: args.accountId,
restricted: args.restricted,
};
try {
await accountApi.updateRestrictionStatus(requestParam, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
const error = createErrorObject(e);
// このAPIでは個別のエラーメッセージは不要
const errorMessage = getTranslationID("common.message.internalServerError");
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -1,10 +1,11 @@
import { RootState } from "app/store";
// 各値はそのまま画面に表示するので、licenseSummaryInfoとして値を取得する
export const selecLicenseSummaryInfo = (state: RootState) =>
export const selectLicenseSummaryInfo = (state: RootState) =>
state.licenseSummary.domain.licenseSummaryInfo;
export const selectCompanyName = (state: RootState) =>
state.licenseSummary.domain.accountInfo.companyName;
export const selectIsLoading = (state: RootState) => state.license;
export const selectIsLoading = (state: RootState) =>
state.licenseSummary.apps.isLoading;

View File

@ -105,3 +105,82 @@ export const getPartnerLicenseAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const switchParentAsync = createAsyncThunk<
{
/* Empty Object */
},
{
// パラメータ
to: number;
children: number[];
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("accounts/switchParentAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const accountsApi = new AccountsApi(config);
const { to, children } = args;
try {
await accountsApi.switchParent(
{
to,
children,
},
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
let errorMessage = getTranslationID("common.message.internalServerError");
// TODO:エラー処理
if (error.code === "E017001") {
errorMessage = getTranslationID(
"changeOwnerPopup.message.accountNotFoundError"
);
}
if (error.code === "E017002") {
errorMessage = getTranslationID(
"changeOwnerPopup.message.hierarchyMismatchError"
);
}
if (error.code === "E017003") {
errorMessage = getTranslationID(
"changeOwnerPopup.message.regionMismatchError"
);
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -1,7 +1,11 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { PartnerLicenseInfo } from "api";
import { PartnerLicensesState, HierarchicalElement } from "./state";
import { getMyAccountAsync, getPartnerLicenseAsync } from "./operations";
import {
getMyAccountAsync,
getPartnerLicenseAsync,
switchParentAsync,
} from "./operations";
import { ACCOUNTS_VIEW_LIMIT } from "./constants";
const initialState: PartnerLicensesState = {
@ -12,6 +16,8 @@ const initialState: PartnerLicensesState = {
tier: 0,
country: "",
delegationPermission: false,
autoFileDelete: false,
fileRetentionDays: 0,
},
total: 0,
ownPartnerLicense: {
@ -107,6 +113,15 @@ export const partnerLicenseSlice = createSlice({
builder.addCase(getPartnerLicenseAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(switchParentAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(switchParentAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(switchParentAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});
export const {

View File

@ -8,6 +8,8 @@ import {
AccountsApi,
CreatePartnerAccountRequest,
GetPartnersResponse,
DeletePartnerAccountRequest,
GetPartnerUsersResponse,
} from "../../api/api";
import { Configuration } from "../../api/configuration";
@ -116,3 +118,170 @@ export const getPartnerInfoAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
// パートナーアカウント削除
export const deletePartnerAccountAsync = createAsyncThunk<
{
/* Empty Object */
},
{
// パラメータ
accountId: number;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("partner/deletePartnerAccountAsync", async (args, thunkApi) => {
const { accountId } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const accountApi = new AccountsApi(config);
try {
const deletePartnerAccountRequest: DeletePartnerAccountRequest = {
targetAccountId: accountId,
};
await accountApi.deletePartnerAccount(deletePartnerAccountRequest, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
const error = createErrorObject(e);
let errorMessage = getTranslationID("common.message.internalServerError");
if (error.code === "E018001") {
errorMessage = getTranslationID(
"partnerPage.message.partnerDeleteFailedError"
);
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({ error });
}
});
// パートナーアカウントユーザー取得
export const getPartnerUsersAsync = createAsyncThunk<
GetPartnerUsersResponse,
{
// パラメータ
accountId: number;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("partner/getPartnerUsersAsync", async (args, thunkApi) => {
const { accountId } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const accountApi = new AccountsApi(config);
try {
const res = await accountApi.getPartnerUsers(
{ targetAccountId: accountId },
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
return res.data;
} catch (e) {
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
// パートナーアカウントユーザー編集
export const editPartnerInfoAsync = createAsyncThunk<
{
/* Empty Object */
},
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("partner/editPartnerInfoAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const accountApi = new AccountsApi(config);
const { id, companyName, selectedAdminId } = state.partner.apps.editPartner;
try {
await accountApi.updatePartnerInfo(
{
targetAccountId: id,
primaryAdminUserId: selectedAdminId,
companyName,
},
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
const error = createErrorObject(e);
let errorMessage = getTranslationID("common.message.internalServerError");
if (error.code === "E010502" || error.code === "E020001") {
errorMessage = getTranslationID("partnerPage.message.editFailedError");
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -1,6 +1,12 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { PartnerState } from "./state";
import { createPartnerAccountAsync, getPartnerInfoAsync } from "./operations";
import {
createPartnerAccountAsync,
getPartnerInfoAsync,
deletePartnerAccountAsync,
getPartnerUsersAsync,
editPartnerInfoAsync,
} from "./operations";
import { LIMIT_PARTNER_VIEW_NUM } from "./constants";
const initialState: PartnerState = {
@ -17,6 +23,13 @@ const initialState: PartnerState = {
adminName: "",
email: "",
},
editPartner: {
users: [],
id: 0,
companyName: "",
country: "",
selectedAdminId: 0,
},
limit: LIMIT_PARTNER_VIEW_NUM,
offset: 0,
isLoading: false,
@ -75,6 +88,37 @@ export const partnerSlice = createSlice({
state.apps.delegatedAccountId = undefined;
state.apps.delegatedCompanyName = undefined;
},
changeEditPartner: (
state,
action: PayloadAction<{
id: number;
companyName: string;
country: string;
}>
) => {
const { id, companyName, country } = action.payload;
state.apps.editPartner.id = id;
state.apps.editPartner.companyName = companyName;
state.apps.editPartner.country = country;
},
changeEditCompanyName: (
state,
action: PayloadAction<{ companyName: string }>
) => {
const { companyName } = action.payload;
state.apps.editPartner.companyName = companyName;
},
changeSelectedAdminId: (
state,
action: PayloadAction<{ adminId: number }>
) => {
const { adminId } = action.payload;
state.apps.editPartner.selectedAdminId = adminId;
},
cleanupPartnerAccount: (state) => {
state.apps.editPartner = initialState.apps.editPartner;
},
},
extraReducers: (builder) => {
builder.addCase(createPartnerAccountAsync.pending, (state) => {
@ -97,6 +141,37 @@ export const partnerSlice = createSlice({
builder.addCase(getPartnerInfoAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(deletePartnerAccountAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(deletePartnerAccountAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(deletePartnerAccountAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(getPartnerUsersAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(getPartnerUsersAsync.fulfilled, (state, action) => {
const { users } = action.payload;
state.apps.editPartner.users = users;
state.apps.editPartner.selectedAdminId =
users.find((user) => user.isPrimaryAdmin)?.id ?? 0;
state.apps.isLoading = false;
});
builder.addCase(getPartnerUsersAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(editPartnerInfoAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(editPartnerInfoAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(editPartnerInfoAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});
export const {
@ -108,5 +183,9 @@ export const {
savePageInfo,
changeDelegateAccount,
cleanupDelegateAccount,
changeEditPartner,
changeEditCompanyName,
changeSelectedAdminId,
cleanupPartnerAccount,
} = partnerSlice.actions;
export default partnerSlice.reducer;

View File

@ -62,3 +62,17 @@ export const selectDelegatedAccountId = (state: RootState) =>
state.partner.apps.delegatedAccountId;
export const selectDelegatedCompanyName = (state: RootState) =>
state.partner.apps.delegatedCompanyName;
// edit
export const selectEditPartnerId = (state: RootState) =>
state.partner.apps.editPartner.id;
export const selectEditPartnerCompanyName = (state: RootState) =>
state.partner.apps.editPartner.companyName;
export const selectEditPartnerCountry = (state: RootState) =>
state.partner.apps.editPartner.country;
export const selectEditPartnerUsers = (state: RootState) =>
state.partner.apps.editPartner.users;
export const selectSelectedAdminId = (state: RootState) =>
state.partner.apps.editPartner.selectedAdminId;

View File

@ -1,6 +1,7 @@
import {
CreatePartnerAccountRequest,
GetPartnersResponse,
PartnerUser,
} from "../../api/api";
export interface PartnerState {
@ -19,4 +20,11 @@ export interface Apps {
isLoading: boolean;
delegatedAccountId?: number;
delegatedCompanyName?: string;
editPartner: {
users: PartnerUser[];
id: number;
companyName: string;
country: string;
selectedAdminId: number;
};
}

View File

@ -9,6 +9,7 @@ import {
UsersApi,
LicensesApi,
GetAllocatableLicensesResponse,
MultipleImportUser,
} from "../../api/api";
import { Configuration } from "../../api/configuration";
import { ErrorObject, createErrorObject } from "../../common/errors";
@ -383,3 +384,189 @@ export const deallocateLicenseAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const deleteUserAsync = createAsyncThunk<
// 正常時の戻り値の型
{
/* Empty Object */
},
// 引数
{
userId: number;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("users/deleteUserAsync", async (args, thunkApi) => {
const { userId } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const usersApi = new UsersApi(config);
try {
await usersApi.deleteUser(
{
userId,
},
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換
const error = createErrorObject(e);
let errorMessage = getTranslationID("common.message.internalServerError");
if (error.statusCode === 400) {
if (error.code === "E014001") {
// ユーザーが削除済みのため成功
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
}
}
// ユーザーに有効なライセンスが割り当たっているため削除不可
if (error.code === "E014007") {
errorMessage = getTranslationID(
"userListPage.message.userDeletionLicenseActiveError"
);
}
// 管理者ユーザーため削除不可
if (error.code === "E014002") {
errorMessage = getTranslationID(
"userListPage.message.adminUserDeletionError"
);
}
// タイピストユーザーで担当タスクがあるため削除不可
if (error.code === "E014009") {
errorMessage = getTranslationID(
"userListPage.message.typistUserDeletionTranscriptionTaskError"
);
}
// タイピストユーザーでルーティングルールに設定されているため削除不可
if (error.code === "E014004") {
errorMessage = getTranslationID(
"userListPage.message.typistDeletionRoutingRuleError"
);
}
// タイピストユーザーでTranscriptionistGroupに所属しているため削除不可
if (error.code === "E014005") {
errorMessage = getTranslationID(
"userListPage.message.typistUserDeletionTranscriptionistGroupError"
);
}
// Authorユーザーで同一AuthorIDのタスクがあるため削除不可
if (error.code === "E014006") {
errorMessage = getTranslationID(
"userListPage.message.authorUserDeletionTranscriptionTaskError"
);
}
// Authorユーザーで同一AuthorIDがルーティングルールに設定されているため削除不可
if (error.code === "E014003") {
errorMessage = getTranslationID(
"userListPage.message.authorDeletionRoutingRuleError"
);
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const importUsersAsync = createAsyncThunk<
// 正常時の戻り値の型
{
/* Empty Object */
},
// 引数
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("users/importUsersAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const { importFileName, importUsers } = state.user.apps;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const usersApi = new UsersApi(config);
try {
if (importFileName === undefined) {
throw new Error("importFileName is undefined");
}
// CSVデータをAPIに送信するためのデータに変換
const users: MultipleImportUser[] = importUsers.map((user) => ({
name: user.name ?? "",
email: user.email ?? "",
role: user.role ?? 0,
authorId: user.author_id ?? undefined,
autoRenew: user.auto_assign ?? 0,
notification: user.notification ?? 0,
encryption: user.encryption ?? undefined,
encryptionPassword: user.encryption_password ?? undefined,
prompt: user.prompt ?? undefined,
}));
await usersApi.multipleImports(
{
filename: importFileName,
users,
},
{ headers: { authorization: `Bearer ${accessToken}` } }
);
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("userListPage.message.importSuccess"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -382,3 +382,142 @@ const convertValueBasedOnLicenseStatus = (
remaining: undefined,
};
};
export const selectImportFileName = (state: RootState) =>
state.user.apps.importFileName;
export const selectImportValidationErrors = (state: RootState) => {
const csvUsers = state.user.apps.importUsers;
let rowNumber = 1;
const invalidInput: number[] = [];
const duplicatedEmailsMap = new Map<string, number>();
const duplicatedAuthorIdsMap = new Map<string, number>();
const overMaxRow = csvUsers.length > 100;
// eslint-disable-next-line no-restricted-syntax
for (const csvUser of csvUsers) {
rowNumber += 1;
// メールアドレスの重複がある場合、エラーとしてその行番号を追加する
const duplicatedEmailUser = csvUsers.filter(
(x) => x.email === csvUser.email
);
if (duplicatedEmailUser.length > 1) {
if (csvUser.email !== null && !duplicatedEmailsMap.has(csvUser.email)) {
duplicatedEmailsMap.set(csvUser.email, rowNumber);
}
}
// AuthorIDの重複がある場合、エラーとしてその行番号を追加する
const duplicatedAuthorIdUser = csvUsers.filter(
(x) => x.author_id === csvUser.author_id
);
if (duplicatedAuthorIdUser.length > 1) {
if (
csvUser.author_id !== null &&
!duplicatedAuthorIdsMap.has(csvUser.author_id)
) {
duplicatedAuthorIdsMap.set(csvUser.author_id, rowNumber);
}
}
// name
if (csvUser.name === null || csvUser.name.length > 225) {
invalidInput.push(rowNumber);
// eslint-disable-next-line no-continue
continue;
}
// email
const emailPattern =
/^[a-zA-Z0-9!#$%&'_`/=~+\-?^{|}.]+@[a-zA-Z0-9!#$%&'_`/=~+\-?^{|}.]*\.[a-zA-Z0-9!#$%&'_`/=~+\-?^{|}.]*[a-zA-Z]$/;
if (
csvUser.name === null ||
csvUser.name.length > 225 ||
!emailPattern.test(csvUser.email ?? "")
) {
invalidInput.push(rowNumber);
// eslint-disable-next-line no-continue
continue;
}
// role
if (csvUser.role === null || ![0, 1, 2].includes(csvUser.role)) {
invalidInput.push(rowNumber);
// eslint-disable-next-line no-continue
continue;
}
// role=1(Author)
if (csvUser.role === 1) {
// author_id
if (csvUser.author_id === null || csvUser.author_id.length > 16) {
invalidInput.push(rowNumber);
// eslint-disable-next-line no-continue
continue;
}
// 半角英数字と_の組み合わせで文字まで
const charaTypePattern = /^[A-Z0-9_]{1,16}$/;
const charaType = new RegExp(charaTypePattern).test(csvUser.author_id);
if (!charaType) {
invalidInput.push(rowNumber);
// eslint-disable-next-line no-continue
continue;
}
// encryption
if (csvUser.encryption === null || ![0, 1].includes(csvUser.encryption)) {
invalidInput.push(rowNumber);
// eslint-disable-next-line no-continue
continue;
}
if (csvUser.encryption === 1) {
// encryption_password
if (csvUser.encryption === 1) {
const regex = /^[!-~]{4,16}$/;
if (!regex.test(csvUser.encryption_password ?? "")) {
invalidInput.push(rowNumber);
// eslint-disable-next-line no-continue
continue;
}
}
}
// prompt
if (csvUser.prompt === null || ![0, 1].includes(csvUser.prompt)) {
invalidInput.push(rowNumber);
// eslint-disable-next-line no-continue
continue;
}
}
// auto_assign
if (csvUser.auto_assign === null || ![0, 1].includes(csvUser.auto_assign)) {
invalidInput.push(rowNumber);
// eslint-disable-next-line no-continue
continue;
}
// notification
if (
csvUser.notification === null ||
![0, 1].includes(csvUser.notification)
) {
invalidInput.push(rowNumber);
// eslint-disable-next-line no-continue
continue;
}
}
const duplicatedEmails = Array.from(duplicatedEmailsMap.values());
const duplicatedAuthorIds = Array.from(duplicatedAuthorIdsMap.values());
return {
invalidInput,
duplicatedEmails,
duplicatedAuthorIds,
overMaxRow,
};
};

View File

@ -1,3 +1,4 @@
import { CSVType } from "common/parser";
import { User, AllocatableLicenseInfo } from "../../api/api";
import { AddUser, UpdateUser, LicenseAllocateUser } from "./types";
@ -19,4 +20,6 @@ export interface Apps {
selectedlicenseId: number;
hasPasswordMask: boolean;
isLoading: boolean;
importFileName: string | undefined;
importUsers: CSVType[];
}

View File

@ -54,14 +54,14 @@ export interface LicenseAllocateUser {
remaining?: number;
}
export type RoleType = typeof USER_ROLES[keyof typeof USER_ROLES];
export type RoleType = (typeof USER_ROLES)[keyof typeof USER_ROLES];
// 受け取った値がUSER_ROLESの型であるかどうかを判定する
export const isRoleType = (role: string): role is RoleType =>
Object.values(USER_ROLES).includes(role as RoleType);
export type LicenseStatusType =
typeof LICENSE_STATUS[keyof typeof LICENSE_STATUS];
(typeof LICENSE_STATUS)[keyof typeof LICENSE_STATUS];
// 受け取った値がLicenseStatusTypeの型であるかどうかを判定する
export const isLicenseStatusType = (

View File

@ -1,5 +1,6 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { USER_ROLES } from "components/auth/constants";
import { CSVType } from "common/parser";
import { UsersState } from "./state";
import {
addUserAsync,
@ -7,6 +8,8 @@ import {
updateUserAsync,
getAllocatableLicensesAsync,
deallocateLicenseAsync,
deleteUserAsync,
importUsersAsync,
} from "./operations";
import { RoleType, UserView } from "./types";
@ -60,6 +63,8 @@ const initialState: UsersState = {
selectedlicenseId: 0,
hasPasswordMask: false,
isLoading: false,
importFileName: undefined,
importUsers: [],
},
};
@ -241,6 +246,21 @@ export const userSlice = createSlice({
state.apps.licenseAllocateUser = initialState.apps.licenseAllocateUser;
state.apps.selectedlicenseId = initialState.apps.selectedlicenseId;
},
changeImportFileName: (
state,
action: PayloadAction<{ fileName: string }>
) => {
const { fileName } = action.payload;
state.apps.importFileName = fileName;
},
changeImportCsv: (state, action: PayloadAction<{ users: CSVType[] }>) => {
const { users } = action.payload;
state.apps.importUsers = users;
},
cleanupImportUsers: (state) => {
state.apps.importFileName = initialState.apps.importFileName;
state.apps.importUsers = initialState.apps.importUsers;
},
},
extraReducers: (builder) => {
builder.addCase(listUsersAsync.pending, (state) => {
@ -290,6 +310,24 @@ export const userSlice = createSlice({
builder.addCase(deallocateLicenseAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(deleteUserAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(deleteUserAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(deleteUserAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(importUsersAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(importUsersAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(importUsersAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});
@ -317,6 +355,9 @@ export const {
changeLicenseAllocateUser,
changeSelectedlicenseId,
cleanupLicenseAllocateInfo,
changeImportFileName,
changeImportCsv,
cleanupImportUsers,
} = userSlice.actions;
export default userSlice.reducer;

View File

@ -115,3 +115,78 @@ export const uploadTemplateAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const deleteTemplateAsync = createAsyncThunk<
{
/* Empty Object */
},
{ templateFileId: number },
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("workflow/deleteTemplateAsync", async (args, thunkApi) => {
const { templateFileId } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const templateApi = new TemplatesApi(config);
try {
// ファイルを削除する
await templateApi.deleteTemplateFile(templateFileId, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
if (error.code === "E016001") {
// テンプレートファイルが削除済みの場合は成功扱いとする
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
}
let message = getTranslationID("common.message.internalServerError");
// テンプレートファイルがルーティングルールに紐づく場合はエラー
if (error.code === "E016002") {
message = getTranslationID(
"templateFilePage.message.deleteFailedWorkflowAssigned"
);
}
// テンプレートファイルが未完了のタスクに紐づく場合はエラー
if (error.code === "E016003") {
message = getTranslationID(
"templateFilePage.message.deleteFailedTaskAssigned"
);
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message,
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -1,6 +1,10 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { TemplateState } from "./state";
import { listTemplateAsync, uploadTemplateAsync } from "./operations";
import {
deleteTemplateAsync,
listTemplateAsync,
uploadTemplateAsync,
} from "./operations";
const initialState: TemplateState = {
apps: {
@ -45,6 +49,15 @@ export const templateSlice = createSlice({
builder.addCase(uploadTemplateAsync.rejected, (state) => {
state.apps.isUploading = false;
});
builder.addCase(deleteTemplateAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(deleteTemplateAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(deleteTemplateAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});

View File

@ -269,3 +269,70 @@ export const updateTypistGroupAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const deleteTypistGroupAsync = createAsyncThunk<
{
/* Empty Object */
},
{
typistGroupId: number;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("workflow/deleteTypistGroupAsync", async (args, thunkApi) => {
const { typistGroupId } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const accountsApi = new AccountsApi(config);
try {
await accountsApi.deleteTypistGroup(typistGroupId, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
// すでに削除されていた場合は成功扱いする
if (error.code === "E015001") {
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
}
// 以下は実際の削除失敗
let message = getTranslationID("common.message.internalServerError");
if (error.code === "E015002")
message = getTranslationID(
"typistGroupSetting.message.deleteFailedWorkflowAssigned"
);
if (error.code === "E015003")
message = getTranslationID(
"typistGroupSetting.message.deleteFailedCheckoutPermissionExisted"
);
thunkApi.dispatch(openSnackbar({ level: "error", message }));
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -6,6 +6,7 @@ import {
listTypistGroupsAsync,
listTypistsAsync,
updateTypistGroupAsync,
deleteTypistGroupAsync,
} from "./operations";
const initialState: TypistGroupState = {
@ -106,6 +107,15 @@ export const typistGroupSlice = createSlice({
builder.addCase(updateTypistGroupAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(deleteTypistGroupAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(deleteTypistGroupAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(deleteTypistGroupAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});

View File

@ -2,7 +2,7 @@ import { OPTION_ITEMS_DEFAULT_VALUE_TYPE } from "./constants";
// OPTION_ITEMS_DEFAULT_VALUE_TYPEからOptionItemDefaultValueTypeを作成する
export type OptionItemsDefaultValueType =
typeof OPTION_ITEMS_DEFAULT_VALUE_TYPE[keyof typeof OPTION_ITEMS_DEFAULT_VALUE_TYPE];
(typeof OPTION_ITEMS_DEFAULT_VALUE_TYPE)[keyof typeof OPTION_ITEMS_DEFAULT_VALUE_TYPE];
// 受け取った値がOptionItemDefaultValueType型かどうかを判定する
export const isOptionItemDefaultValueType = (

View File

@ -0,0 +1,169 @@
import React, { useCallback, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
selectInputValidationErrors,
selectFileDeleteSetting,
updateFileDeleteSettingAsync,
selectIsLoading,
getAccountRelationsAsync,
} from "features/account";
import { AppDispatch } from "app/store";
import { useTranslation } from "react-i18next";
import styles from "../../styles/app.module.scss";
import { getTranslationID } from "../../translation";
import close from "../../assets/images/close.svg";
import {
changeAutoFileDelete,
changeFileRetentionDays,
} from "../../features/account/accountSlice";
import progress_activit from "../../assets/images/progress_activit.svg";
interface FileDeleteSettingPopupProps {
// eslint-disable-next-line react/no-unused-prop-types
onClose: () => void;
}
export const FileDeleteSettingPopup: React.FC<FileDeleteSettingPopupProps> = (
props
) => {
const { onClose } = props;
const dispatch: AppDispatch = useDispatch();
const [t] = useTranslation();
const isLoading = useSelector(selectIsLoading);
const fileDeleteSetting = useSelector(selectFileDeleteSetting);
const { hasFileRetentionDaysError } = useSelector(
selectInputValidationErrors
);
const closePopup = useCallback(() => {
if (isLoading) return;
onClose();
}, [isLoading, onClose]);
const [isPushSubmitButton, setIsPushSubmitButton] = useState<boolean>(false);
const onUpdateFileDeleteSetting = useCallback(async () => {
if (isLoading) return;
setIsPushSubmitButton(true);
if (hasFileRetentionDaysError) {
return;
}
const { meta } = await dispatch(
updateFileDeleteSettingAsync({
autoFileDelete: fileDeleteSetting.autoFileDelete,
fileRetentionDays: fileDeleteSetting.fileRetentionDays,
})
);
setIsPushSubmitButton(false);
if (meta.requestStatus === "fulfilled") {
closePopup();
dispatch(getAccountRelationsAsync());
}
}, [
closePopup,
dispatch,
fileDeleteSetting.autoFileDelete,
fileDeleteSetting.fileRetentionDays,
hasFileRetentionDaysError,
isLoading,
]);
return (
<div className={`${styles.modal} ${styles.isShow}`}>
<div className={styles.modalBox}>
<p className={styles.modalTitle}>
{t(getTranslationID("fileDeleteSettingPopup.label.title"))}
<button type="button" onClick={closePopup}>
<img src={close} className={styles.modalTitleIcon} alt="close" />
</button>
</p>
<form className={styles.form}>
<dl className={`${styles.formList} ${styles.hasbg}`}>
<dt className={styles.formTitle} />
<dt>
{t(
getTranslationID(
"fileDeleteSettingPopup.label.autoFileDeleteCheck"
)
)}
</dt>
<dd className={styles.last}>
<p>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
<input
type="checkbox"
className={styles.formCheck}
checked={fileDeleteSetting.autoFileDelete}
onChange={(e) => {
dispatch(
changeAutoFileDelete({
autoFileDelete: e.target.checked,
})
);
}}
/>
</label>
</p>
<p className={styles.txWsline}>
{t(
getTranslationID(
"fileDeleteSettingPopup.label.daysAnnotation"
)
)}
</p>
<input
type="number"
min={1}
max={999}
value={fileDeleteSetting.fileRetentionDays}
className={`${styles.formInput} ${styles.short}`}
disabled={!fileDeleteSetting.autoFileDelete}
onChange={(e) => {
dispatch(
changeFileRetentionDays({
fileRetentionDays: Number(e.target.value),
})
);
}}
/>{" "}
{t(getTranslationID("fileDeleteSettingPopup.label.days"))}
{isPushSubmitButton && hasFileRetentionDaysError && (
<span className={styles.formError}>
{t(
getTranslationID(
"fileDeleteSettingPopup.label.daysValidationError"
)
)}
</span>
)}
</dd>
<dd className={`${styles.full} ${styles.alignCenter}`}>
<input
type="button"
name="submit"
value={t(
getTranslationID("fileDeleteSettingPopup.label.saveButton")
)}
className={`${styles.formSubmit} ${styles.marginBtm1} ${
!isLoading ? styles.isActive : ""
}`}
onClick={onUpdateFileDeleteSetting}
disabled={isLoading}
/>
</dd>
<img
style={{ display: isLoading ? "inline" : "none" }}
src={progress_activit}
className={styles.icLoading}
alt="Loading"
/>
</dl>
</form>
</div>
</div>
);
};

View File

@ -23,6 +23,7 @@ import { getTranslationID } from "translation";
import { TIERS } from "components/auth/constants";
import { isApproveTier } from "features/auth";
import { DeleteAccountPopup } from "./deleteAccountPopup";
import { FileDeleteSettingPopup } from "./fileDeleteSettingPopup";
import progress_activit from "../../assets/images/progress_activit.svg";
const AccountPage: React.FC = (): JSX.Element => {
@ -40,10 +41,17 @@ const AccountPage: React.FC = (): JSX.Element => {
const [isDeleteAccountPopupOpen, setIsDeleteAccountPopupOpen] =
useState(false);
const [isFileDeleteSettingPopupOpen, setIsFileDeleteSettingPopupOpen] =
useState(false);
const onDeleteAccountOpen = useCallback(() => {
setIsDeleteAccountPopupOpen(true);
}, [setIsDeleteAccountPopupOpen]);
const onDeleteFileDeleteSettingOpen = useCallback(() => {
setIsFileDeleteSettingPopupOpen(true);
}, [setIsFileDeleteSettingPopupOpen]);
// 階層表示用
const tierNames: { [key: number]: string } = {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -89,6 +97,13 @@ const AccountPage: React.FC = (): JSX.Element => {
}}
/>
)}
{isFileDeleteSettingPopupOpen && (
<FileDeleteSettingPopup
onClose={() => {
setIsFileDeleteSettingPopupOpen(false);
}}
/>
)}
<div className={styles.wrap}>
<Header />
<UpdateTokenTimer />
@ -102,12 +117,13 @@ const AccountPage: React.FC = (): JSX.Element => {
<section className={styles.account}>
<div className={styles.boxFlex}>
{/* File Delete Setting
<ul className={`${styles.menuAction} ${styles.box100}`}>
<li>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<a
href="account_setting.html"
className={`${styles.menuLink} ${styles.isActive}`}
onClick={onDeleteFileDeleteSettingOpen}
data-tag="open-file-delete-setting-popup"
>
<img
src="images/file_delete.svg"
@ -120,7 +136,6 @@ const AccountPage: React.FC = (): JSX.Element => {
</a>
</li>
</ul>
*/}
<div className={styles.marginRgt3}>
<dl className={styles.listVertical}>
@ -216,9 +231,23 @@ const AccountPage: React.FC = (): JSX.Element => {
)
)}
</dd>
<dd
style={{ paddingBottom: 0 }}
className={`${styles.full}`}
/>
</>
)}
{!isTier5 && <dd>-</dd>}
<dt>
{t(
getTranslationID("accountPage.label.fileRetentionDays")
)}
</dt>
<dd>
{viewInfo.account.autoFileDelete
? viewInfo.account.fileRetentionDays
: "-"}
</dd>
</dl>
</div>

View File

@ -1,13 +1,15 @@
import React, { useCallback } from "react";
import React, { useCallback, useEffect, useState } from "react";
import styles from "styles/app.module.scss";
import { useSelector } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import {
selectSelectedFileTask,
selectIsLoading,
PRIORITY,
renameFileAsync,
} from "features/dictation";
import { getTranslationID } from "translation";
import { useTranslation } from "react-i18next";
import { AppDispatch } from "app/store";
import close from "../../assets/images/close.svg";
import lock from "../../assets/images/lock.svg";
@ -19,14 +21,55 @@ interface FilePropertyPopupProps {
export const FilePropertyPopup: React.FC<FilePropertyPopupProps> = (props) => {
const { onClose, isOpen } = props;
const [t] = useTranslation();
const dispatch: AppDispatch = useDispatch();
const isLoading = useSelector(selectIsLoading);
const [isPushSaveButton, setIsPushSaveButton] = useState<boolean>(false);
// ポップアップを閉じる処理
const closePopup = useCallback(() => {
setIsPushSaveButton(false);
onClose(false);
}, [onClose]);
const selectedFileTask = useSelector(selectSelectedFileTask);
const [fileName, setFileName] = useState<string>("");
useEffect(() => {
if (isOpen) {
setFileName(selectedFileTask?.fileName ?? "");
}
}, [selectedFileTask, isOpen]);
// ファイル名の保存処理
const saveFileName = useCallback(async () => {
setIsPushSaveButton(true);
if (fileName.length === 0) {
return;
}
// ダイアログ確認
if (
/* eslint-disable-next-line no-alert */
!window.confirm(t(getTranslationID("common.message.dialogConfirm")))
) {
return;
}
const { meta } = await dispatch(
renameFileAsync({
audioFileId: selectedFileTask?.audioFileId ?? 0,
fileName,
})
);
setIsPushSaveButton(false);
if (meta.requestStatus === "fulfilled") {
onClose(true);
}
}, [t, dispatch, onClose, fileName, selectedFileTask]);
return (
<div className={`${styles.modal} ${isOpen ? styles.isShow : ""}`}>
<div className={styles.modalBox}>
@ -45,7 +88,41 @@ export const FilePropertyPopup: React.FC<FilePropertyPopupProps> = (props) => {
{t(getTranslationID("filePropertyPopup.label.general"))}
</dt>
<dt>{t(getTranslationID("dictationPage.label.fileName"))}</dt>
<dd>{selectedFileTask?.fileName.replace(".zip", "") ?? ""}</dd>
<dd className={styles.hasInput}>
<input
type="text"
size={40}
maxLength={64}
value={fileName}
className={`${styles.formInput} ${styles.short} ${
isPushSaveButton && fileName.length === 0 && styles.isError
}`}
onChange={(e) => setFileName(e.target.value)}
/>
<input
type="button"
name="submit"
value={t(getTranslationID("dictationPage.label.fileNameSave"))}
className={`${styles.formSubmit} ${styles.isActive}`}
style={{
position: "relative",
marginTop: "0.2rem",
right: "auto",
maxWidth: "18rem",
whiteSpace: "normal",
overflowWrap: "break-word",
fontSize: "small",
}}
onClick={saveFileName}
/>
{isPushSaveButton && fileName.length === 0 && (
<span className={styles.formError}>
{t(getTranslationID("common.message.inputEmptyError"))}
</span>
)}
</dd>
<dt>{t(getTranslationID("dictationPage.label.rawFileName"))}</dt>
<dd>{selectedFileTask?.rawFileName ?? ""}</dd>
<dt>{t(getTranslationID("dictationPage.label.fileSize"))}</dt>
<dd>{selectedFileTask?.fileSize ?? ""}</dd>
<dt>{t(getTranslationID("dictationPage.label.fileLength"))}</dt>

View File

@ -33,6 +33,7 @@ import {
playbackAsync,
cancelAsync,
PRIORITY,
deleteTaskAsync,
isSortableColumnType,
isDirectionType,
} from "features/dictation";
@ -63,6 +64,8 @@ const DictationPage: React.FC = (): JSX.Element => {
const isTypist = isTypistUser();
const isNone = !isAuthor && !isTypist;
const isDeletableRole = isAdmin || isAuthor;
// popup制御関係
const [
isChangeTranscriptionistPopupOpen,
@ -496,9 +499,39 @@ const DictationPage: React.FC = (): JSX.Element => {
setIsBackupPopupOpen(true);
}, []);
const onCloseFilePropertyPopup = useCallback(() => {
setIsFilePropertyPopupOpen(false);
}, []);
const onCloseFilePropertyPopup = useCallback(
(isChanged: boolean) => {
if (isChanged) {
const filter = getFilter(
filterUploaded,
filterInProgress,
filterPending,
filterFinished,
filterBackup
);
dispatch(
listTasksAsync({
limit: LIMIT_TASK_NUM,
offset: 0,
filter,
direction: sortDirection,
paramName: sortableParamName,
})
);
}
setIsFilePropertyPopupOpen(false);
},
[
dispatch,
filterUploaded,
filterInProgress,
filterPending,
filterFinished,
filterBackup,
sortDirection,
sortableParamName,
]
);
const sortIconClass = (
currentParam: SortableColumnType,
@ -514,6 +547,53 @@ const DictationPage: React.FC = (): JSX.Element => {
return styles.isActiveAz;
};
const onDeleteTask = useCallback(
async (audioFileId: number) => {
if (
/* eslint-disable-next-line no-alert */
!window.confirm(t(getTranslationID("common.message.dialogConfirm")))
) {
return;
}
const { meta } = await dispatch(
deleteTaskAsync({
audioFileId,
})
);
if (meta.requestStatus === "fulfilled") {
const filter = getFilter(
filterUploaded,
filterInProgress,
filterPending,
filterFinished,
filterBackup
);
dispatch(
listTasksAsync({
limit: LIMIT_TASK_NUM,
offset: 0,
filter,
direction: sortDirection,
paramName: sortableParamName,
})
);
dispatch(listTypistsAsync());
dispatch(listTypistGroupsAsync());
}
},
[
dispatch,
filterBackup,
filterFinished,
filterInProgress,
filterPending,
filterUploaded,
sortDirection,
sortableParamName,
t,
]
);
// 初回読み込み処理
useEffect(() => {
(async () => {
@ -1183,17 +1263,26 @@ const DictationPage: React.FC = (): JSX.Element => {
)}
</a>
</li>
{/* CCB
<li>
<a>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
// タスクのステータスがInprogressまたはPending以外の場合、削除ボタンを活性化する
className={
isDeletableRole &&
x.status !== STATUS.INPROGRESS &&
x.status !== STATUS.PENDING
? ""
: styles.isDisable
}
onClick={() => onDeleteTask(x.audioFileId)}
>
{t(
getTranslationID(
"dictationPage.label.deleteDictation"
)
)}
</a>
</li>
*/}
</li>
</ul>
</td>
{displayColumn.JobNumber && (
@ -1252,9 +1341,7 @@ const DictationPage: React.FC = (): JSX.Element => {
<td className={styles.clm6}>{x.workType}</td>
)}
{displayColumn.FileName && (
<td className={styles.clm7}>
{x.fileName.replace(".zip", "")}
</td>
<td className={styles.clm7}>{x.fileName}</td>
)}
{displayColumn.FileLength && (
<td className={styles.clm8}>{x.audioDuration}</td>

View File

@ -0,0 +1,203 @@
import React, { useState, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
selectChildrenPartnerLicenses,
selectIsLoading,
selectOwnPartnerLicense,
} from "features/license/partnerLicense/selectors";
import {
getMyAccountAsync,
switchParentAsync,
} from "features/license/partnerLicense/operations";
import { useTranslation } from "react-i18next";
import { getTranslationID } from "translation";
import { AppDispatch } from "app/store";
import { clearHierarchicalElement } from "features/license/partnerLicense";
import styles from "../../styles/app.module.scss";
import close from "../../assets/images/close.svg";
import shuffle from "../../assets/images/shuffle.svg";
import progress_activit from "../../assets/images/progress_activit.svg";
interface ChangeOwnerPopupProps {
onClose: () => void;
}
const ChangeOwnerPopup: React.FC<ChangeOwnerPopupProps> = (props) => {
const dispatch: AppDispatch = useDispatch();
const { t } = useTranslation();
const [selectedChildId, setSelectedChildId] = useState<number | null>(null);
const [selectedChildName, setSelectedChildName] = useState<string>("");
const [destinationParentId, setDestinationParentId] = useState<string>("");
const [error, setError] = useState<string>("");
const originParentLicenseInfo = useSelector(selectOwnPartnerLicense);
const childrenLicenseInfos = useSelector(selectChildrenPartnerLicenses);
const isLoading = useSelector(selectIsLoading);
const { onClose } = props;
const closePopup = useCallback(() => {
if (isLoading) return;
onClose();
}, [isLoading, onClose]);
const bulkDisplayName = "-- Bulk --";
const bulkValue = "bulk";
const onBulkChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const { value } = e.target;
const childId = value === bulkValue ? null : Number(value);
setSelectedChildId(childId);
// 一括追加のときは子アカウント名を表示しない
let childName = "";
if (childId) {
const child = childrenLicenseInfos.find((c) => c.accountId === childId);
// childがundefinedになることはないが、コード解析対応のためのチェック
if (child) {
childName = child.companyName;
}
}
setSelectedChildName(childName);
},
[childrenLicenseInfos]
);
const onSaveClick = useCallback(async () => {
const destinationParentIdNum = Number(destinationParentId);
if (
Number.isNaN(destinationParentIdNum) || // 数値でない場合
destinationParentIdNum <= 0 || // IDにならない数値の場合
destinationParentId.length > 7 // 8桁以上の場合本システムの特徴として8桁以上になることはあり得ない
) {
setError(t(getTranslationID("changeOwnerPopup.label.invalidInputError")));
return;
}
setError("");
if (
// eslint-disable-next-line no-alert
!window.confirm(t(getTranslationID("common.message.dialogConfirm")))
) {
return;
}
const children = selectedChildId
? [selectedChildId]
: childrenLicenseInfos.map((child) => child.accountId);
const { meta } = await dispatch(
switchParentAsync({ to: Number(destinationParentId), children })
);
if (meta.requestStatus === "fulfilled") {
dispatch(getMyAccountAsync());
dispatch(clearHierarchicalElement());
closePopup();
}
}, [
childrenLicenseInfos,
closePopup,
destinationParentId,
dispatch,
selectedChildId,
t,
]);
return (
<div className={`${styles.modal} ${styles.isShow}`}>
<div className={styles.modalBox}>
<p className={styles.modalTitle}>
{t(getTranslationID("changeOwnerPopup.label.title"))}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions */}
<img
src={close}
className={styles.modalTitleIcon}
alt="close"
onClick={closePopup}
/>
</p>
<form action="" name="" method="" className={styles.form}>
<dl className={`${styles.formList} ${styles.hasbg}`}>
<dt className={styles.formTitle} />
<dt>
{t(getTranslationID("changeOwnerPopup.label.upperLayerId"))}
</dt>
<dd className={styles.ownerChange}>
<p className={styles.Owner}>
<input
type="text"
size={40}
name=""
value={originParentLicenseInfo.accountId}
readOnly
className={`${styles.formInput} ${styles.short}`}
/>
<span className={styles.txName}>
{originParentLicenseInfo.companyName}
</span>
</p>
<p className={styles.arrowR} />
<p className={styles.newOwner}>
<input
type="text"
size={40}
name=""
value={destinationParentId}
placeholder=" "
className={`${styles.formInput} ${styles.short}`}
onChange={(e) => setDestinationParentId(e.target.value)}
/>
<span className={styles.formError}>{error}</span>
</p>
</dd>
<dd className={styles.full}>
<img src={shuffle} className={styles.transOwner} alt="" />
</dd>
<dt>
{t(getTranslationID("changeOwnerPopup.label.lowerLayerId"))}
</dt>
<dd className={styles.lowerTrans}>
<select
name=""
className={`${styles.formInput} ${styles.short}`}
value={selectedChildId ?? bulkDisplayName}
onChange={onBulkChange}
>
<option value={bulkValue}>{bulkDisplayName}</option>
{childrenLicenseInfos.map((child) => (
<option key={child.accountId} value={child.accountId}>
{child.accountId}
</option>
))}
</select>
<span className={styles.txName}>{selectedChildName}</span>
</dd>
<dd className={`${styles.full} ${styles.alignCenter}`}>
{/* 処理中や子アカウントが1件も存在しない場合、Saveボタンを押せないようにする */}
<input
type="button"
name="submit"
value={t(getTranslationID("common.label.save"))}
className={`${styles.formSubmit} ${styles.marginBtm1} ${
!isLoading && childrenLicenseInfos.length > 0
? styles.isActive
: ""
}`}
onClick={onSaveClick}
disabled={isLoading || childrenLicenseInfos.length <= 0}
/>
</dd>
<img
style={{ display: isLoading ? "inline" : "none" }}
src={progress_activit}
className={styles.icLoading}
alt="Loading"
/>
</dl>
</form>
</div>
</div>
);
};
export default ChangeOwnerPopup;

View File

@ -45,9 +45,10 @@ export const LicenseOrderPopup: React.FC<LicenseOrderPopupProps> = (props) => {
// ポップアップを閉じる処理
const closePopup = useCallback(() => {
if (isLoading) return;
setIsPushOrderButton(false);
onClose();
}, [onClose]);
}, [isLoading, onClose]);
// 画面からのパラメータ
const poNumber = useSelector(selectPoNumber);

View File

@ -10,12 +10,16 @@ import { useDispatch, useSelector } from "react-redux";
import {
getCompanyNameAsync,
getLicenseSummaryAsync,
selecLicenseSummaryInfo,
selectLicenseSummaryInfo,
selectCompanyName,
selectIsLoading,
updateRestrictionStatusAsync,
} from "features/license/licenseSummary";
import { selectSelectedRow } from "features/license/partnerLicense";
import { selectDelegationAccessToken } from "features/auth/selectors";
import { DelegationBar } from "components/delegate";
import { TIERS } from "components/auth/constants";
import { isAdminUser, isApproveTier } from "features/auth/utils";
import postAdd from "../../assets/images/post_add.svg";
import history from "../../assets/images/history.svg";
import key from "../../assets/images/key.svg";
@ -40,6 +44,8 @@ export const LicenseSummary: React.FC<LicenseSummaryProps> = (
// 代行操作用のトークンを取得する
const delegationAccessToken = useSelector(selectDelegationAccessToken);
const isLoading = useSelector(selectIsLoading);
// popup制御関係
const [islicenseOrderPopupOpen, setIslicenseOrderPopupOpen] = useState(false);
const [isCardLicenseActivatePopupOpen, setIsCardLicenseActivatePopupOpen] =
@ -62,9 +68,12 @@ export const LicenseSummary: React.FC<LicenseSummaryProps> = (
}, [setIsLicenseOrderHistoryOpen]);
// apiからの値取得関係
const licenseSummaryInfo = useSelector(selecLicenseSummaryInfo);
const licenseSummaryInfo = useSelector(selectLicenseSummaryInfo);
const companyName = useSelector(selectCompanyName);
const isTier1 = isApproveTier([TIERS.TIER1]);
const isAdmin = isAdminUser();
useEffect(() => {
dispatch(getLicenseSummaryAsync({ selectedRow }));
dispatch(getCompanyNameAsync({ selectedRow }));
@ -78,6 +87,35 @@ export const LicenseSummary: React.FC<LicenseSummaryProps> = (
}
}, [onReturn]);
const onStorageAvailableChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
if (
/* eslint-disable-next-line no-alert */
!window.confirm(
t(
getTranslationID(
"LicenseSummaryPage.message.storageUnavalableSwitchingConfirm"
)
)
)
) {
return;
}
const restricted = e.target.checked;
const accountId = selectedRow?.accountId;
// 本関数が実行されるときはselectedRowが存在する前提のため、accountIdが存在しない場合の処理は不要
if (!accountId) return;
const { meta } = await dispatch(
updateRestrictionStatusAsync({ accountId, restricted })
);
if (meta.requestStatus === "fulfilled") {
dispatch(getLicenseSummaryAsync({ selectedRow }));
}
},
[dispatch, selectedRow, t]
);
return (
<>
{/* isPopupOpenがfalseの場合はポップアップのhtmlを生成しないように対応。これによりポップアップは都度生成されて初期化の考慮が減る */}
@ -272,6 +310,27 @@ export const LicenseSummary: React.FC<LicenseSummaryProps> = (
</dl>
</div>
<div>
{isTier1 && isAdmin && (
<p
className={`${styles.checkAvail} ${styles.alignRight}`}
>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
<input
type="checkbox"
className={styles.formCheck}
checked={licenseSummaryInfo.isStorageAvailable}
disabled={isLoading}
onChange={onStorageAvailableChange}
/>
{t(
getTranslationID(
"LicenseSummaryPage.label.storageUnavailableCheckbox"
)
)}
</label>
</p>
)}
<dl
className={`${styles.listVertical} ${styles.marginBtm3}`}
>
@ -289,17 +348,31 @@ export const LicenseSummary: React.FC<LicenseSummaryProps> = (
)
)}
</dt>
{/* Storage Usedの値表示をハイフンに置き換え */}
{/* <dd>{licenseSummaryInfo.storageSize}GB</dd> */}
<dd>-</dd>
<dd>
{/** Byte単位で受け取った値をGB単位で表示するため1000^3で割っている小数点以下第三位まで表示で第四位で四捨五入 */}
{(
licenseSummaryInfo.storageSize /
1000 /
1000 /
1000
).toFixed(3)}
GB
</dd>
<dt>
{t(
getTranslationID("LicenseSummaryPage.label.usedSize")
)}
</dt>
{/* Storage Usedの値表示をハイフンに置き換え */}
{/* <dd>{licenseSummaryInfo.usedSize}GB</dd> */}
<dd>-</dd>
<dd>
{/** Byte単位で受け取った値をGB単位で表示するため1000^3で割っている小数点以下第三位まで表示で第四位で四捨五入 */}
{(
licenseSummaryInfo.usedSize /
1000 /
1000 /
1000
).toFixed(3)}
GB
</dd>
<dt className={styles.overLine}>
{t(
getTranslationID(

View File

@ -12,6 +12,7 @@ import { CardLicenseIssuePopup } from "./cardLicenseIssuePopup";
import postAdd from "../../assets/images/post_add.svg";
import history from "../../assets/images/history.svg";
import returnLabel from "../../assets/images/undo.svg";
import changeOwnerIcon from "../../assets/images/change_circle.svg";
import { isApproveTier } from "../../features/auth/utils";
import { TIERS } from "../../components/auth/constants";
import {
@ -37,6 +38,7 @@ import { LicenseOrderPopup } from "./licenseOrderPopup";
import { LicenseOrderHistory } from "./licenseOrderHistory";
import { LicenseSummary } from "./licenseSummary";
import progress_activit from "../../assets/images/progress_activit.svg";
import ChangeOwnerPopup from "./changeOwnerPopup";
const PartnerLicense: React.FC = (): JSX.Element => {
const dispatch: AppDispatch = useDispatch();
@ -49,6 +51,7 @@ const PartnerLicense: React.FC = (): JSX.Element => {
const [islicenseOrderHistoryOpen, setIslicenseOrderHistoryOpen] =
useState(false);
const [isViewDetailsOpen, setIsViewDetailsOpen] = useState(false);
const [isChangeOwnerPopupOpen, setIsChangeOwnerPopupOpen] = useState(false);
// 階層表示用
const tierNames: { [key: number]: string } = {
@ -148,6 +151,11 @@ const PartnerLicense: React.FC = (): JSX.Element => {
[dispatch, setIslicenseOrderHistoryOpen]
);
// changeOwnerボタン押下時
const onClickChangeOwner = useCallback(() => {
setIsChangeOwnerPopupOpen(true);
}, [setIsChangeOwnerPopupOpen]);
// マウント時のみ実行
useEffect(() => {
dispatch(getMyAccountAsync());
@ -245,6 +253,13 @@ const PartnerLicense: React.FC = (): JSX.Element => {
}}
/>
)}
{isChangeOwnerPopupOpen && (
<ChangeOwnerPopup
onClose={() => {
setIsChangeOwnerPopupOpen(false);
}}
/>
)}
{!islicenseOrderHistoryOpen && !isViewDetailsOpen && (
<div className={styles.wrap}>
<Header />
@ -329,6 +344,26 @@ const PartnerLicense: React.FC = (): JSX.Element => {
</a>
)}
</li>
<li>
{isVisibleChangeOwner(ownPartnerLicenseInfo.tier) && (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<a
className={`${styles.menuLink} ${styles.isActive}`}
onClick={onClickChangeOwner}
>
<img
src={changeOwnerIcon}
alt=""
className={styles.menuIcon}
/>
{t(
getTranslationID(
"partnerLicense.label.changeOwnerButton"
)
)}
</a>
)}
</li>
</ul>
<ul className={styles.brCrumbLicense}>
{hierarchicalElements.map((value) => (
@ -545,4 +580,10 @@ const PartnerLicense: React.FC = (): JSX.Element => {
);
};
const isVisibleChangeOwner = (partnerTier: number) =>
// 自身が第一階層または第二階層で、表示しているパートナーが第三階層または第四階層の場合のみ表示
isApproveTier([TIERS.TIER1, TIERS.TIER2]) &&
(partnerTier.toString() === TIERS.TIER3 ||
partnerTier.toString() === TIERS.TIER4);
export default PartnerLicense;

View File

@ -0,0 +1,178 @@
import { AppDispatch } from "app/store";
import React, { useCallback, useEffect } from "react";
import styles from "styles/app.module.scss";
import { useDispatch, useSelector } from "react-redux";
import { getTranslationID } from "translation";
import { useTranslation } from "react-i18next";
import {
changeEditCompanyName,
changeSelectedAdminId,
cleanupPartnerAccount,
getPartnerUsersAsync,
editPartnerInfoAsync,
selectEditPartnerCompanyName,
selectEditPartnerCountry,
selectEditPartnerId,
selectEditPartnerUsers,
selectIsLoading,
selectSelectedAdminId,
selectOffset,
getPartnerInfoAsync,
LIMIT_PARTNER_VIEW_NUM,
} from "features/partner";
import close from "../../assets/images/close.svg";
import progress_activit from "../../assets/images/progress_activit.svg";
import { COUNTRY_LIST } from "../SignupPage/constants";
interface EditPartnerAccountPopup {
isOpen: boolean;
onClose: () => void;
}
export const EditPartnerAccountPopup: React.FC<EditPartnerAccountPopup> = (
props
) => {
const { isOpen, onClose } = props;
const dispatch: AppDispatch = useDispatch();
const { t } = useTranslation();
const isLoading = useSelector(selectIsLoading);
const offset = useSelector(selectOffset);
const partnerId = useSelector(selectEditPartnerId);
const companyName = useSelector(selectEditPartnerCompanyName);
const country = useSelector(selectEditPartnerCountry);
const users = useSelector(selectEditPartnerUsers);
const adminUser = users.find((user) => user.isPrimaryAdmin);
const selectedAdminId = useSelector(selectSelectedAdminId);
// ポップアップを閉じる処理
const closePopup = useCallback(() => {
if (isLoading) {
return;
}
dispatch(cleanupPartnerAccount());
onClose();
}, [isLoading, onClose, dispatch]);
useEffect(() => {
if (isOpen) {
dispatch(getPartnerUsersAsync({ accountId: partnerId }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const onEditPartner = useCallback(async () => {
// eslint-disable-next-line no-alert
if (!window.confirm(t(getTranslationID("common.message.dialogConfirm")))) {
return;
}
const { meta } = await dispatch(editPartnerInfoAsync());
if (meta.requestStatus === "fulfilled") {
dispatch(
getPartnerInfoAsync({
limit: LIMIT_PARTNER_VIEW_NUM,
offset,
})
);
closePopup();
}
}, [dispatch, closePopup, t, offset]);
return (
<div className={`${styles.modal} ${isOpen ? styles.isShow : ""}`}>
<div className={styles.modalBox}>
<p className={styles.modalTitle}>
{t(getTranslationID("partnerPage.label.editAccount"))}
<button type="button" onClick={closePopup}>
<img src={close} className={styles.modalTitleIcon} alt="close" />
</button>
</p>
<form className={styles.form}>
<dl className={`${styles.formList} ${styles.hasbg}`}>
<dt className={styles.formTitle}>
{t(getTranslationID("partnerPage.label.accountInformation"))}
</dt>
<dt>{t(getTranslationID("partnerPage.label.name"))}</dt>
<dd>
<input
type="text"
size={40}
maxLength={255}
value={companyName}
className={styles.formInput}
onChange={(e) => {
dispatch(
changeEditCompanyName({ companyName: e.target.value })
);
}}
/>
</dd>
<dt>{t(getTranslationID("partnerPage.label.country"))}</dt>
<dd className={styles.last}>
<input
type="text"
size={40}
value={COUNTRY_LIST.find((c) => c.value === country)?.label}
className={styles.formInput}
readOnly
/>
</dd>
<dt className={styles.formTitle}>
{t(getTranslationID("partnerPage.label.primaryAdminInfo"))}
</dt>
<dt>{t(getTranslationID("partnerPage.label.adminName"))}</dt>
<dd>
<input
type="text"
size={40}
value={adminUser?.name}
className={styles.formInput}
readOnly
/>
</dd>
<dt>{t(getTranslationID("partnerPage.label.email"))}</dt>
<dd className={styles.last}>
<select
className={styles.formInput}
onChange={(event) => {
dispatch(
changeSelectedAdminId({
adminId: Number(event.target.value),
})
);
}}
value={selectedAdminId}
>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.email}
</option>
))}
</select>
</dd>
<dd className={`${styles.full} ${styles.alignCenter}`}>
<input
type="button"
name="submit"
value={t(getTranslationID("partnerPage.label.saveChanges"))}
className={`${styles.formSubmit} ${styles.marginBtm1} ${
!isLoading && companyName.length !== 0 ? styles.isActive : ""
}`}
onClick={onEditPartner}
/>
<img
style={{ display: isLoading ? "inline" : "none" }}
src={progress_activit}
className={styles.icLoading}
alt="Loading"
/>
</dd>
</dl>
</form>
</div>
</div>
);
};

View File

@ -16,23 +16,28 @@ import {
selectTotalPage,
getPartnerInfoAsync,
selectPartnersInfo,
deletePartnerAccountAsync,
} from "features/partner/index";
import {
changeDelegateAccount,
changeEditPartner,
savePageInfo,
} from "features/partner/partnerSlice";
import { getTranslationID } from "translation";
import { useTranslation } from "react-i18next";
import { getDelegationTokenAsync } from "features/auth/operations";
import { useNavigate } from "react-router-dom";
import { Partner } from "api";
import personAdd from "../../assets/images/person_add.svg";
import { TIERS } from "../../components/auth/constants";
import { AddPartnerAccountPopup } from "./addPartnerAccountPopup";
import { EditPartnerAccountPopup } from "./editPartnerAccountPopup";
import checkFill from "../../assets/images/check_fill.svg";
const PartnerPage: React.FC = (): JSX.Element => {
const dispatch: AppDispatch = useDispatch();
const [isPopupOpen, setIsPopupOpen] = useState(false);
const [isEditPopupOpen, setIsEditPopupOpen] = useState(false);
const [t] = useTranslation();
const navigate = useNavigate();
const total = useSelector(selectTotal);
@ -71,6 +76,19 @@ const PartnerPage: React.FC = (): JSX.Element => {
const onOpen = useCallback(() => {
setIsPopupOpen(true);
}, [setIsPopupOpen]);
const onOpenEditPopup = useCallback(
(editPartner: Partner) => {
dispatch(
changeEditPartner({
id: editPartner.accountId,
companyName: editPartner.name,
country: editPartner.country,
})
);
setIsEditPopupOpen(true);
},
[setIsEditPopupOpen, dispatch]
);
// パートナー取得APIを呼び出す
useEffect(() => {
@ -109,6 +127,31 @@ const PartnerPage: React.FC = (): JSX.Element => {
[dispatch, navigate, t]
);
// delete account押下時処理
const onDeleteAccount = useCallback(
async (accountId: number, companyName: string) => {
// ダイアログ確認
if (
/* eslint-disable-next-line no-alert */
!window.confirm(
`${t(
getTranslationID("partnerPage.message.partnerDeleteConfirm")
)} ${companyName}`
)
) {
return;
}
const { meta } = await dispatch(deletePartnerAccountAsync({ accountId }));
if (meta.requestStatus === "fulfilled") {
dispatch(
getPartnerInfoAsync({ limit: LIMIT_PARTNER_VIEW_NUM, offset })
);
}
},
[dispatch, t, offset]
);
// HTML
return (
<>
@ -118,6 +161,12 @@ const PartnerPage: React.FC = (): JSX.Element => {
setIsPopupOpen(false);
}}
/>
<EditPartnerAccountPopup
isOpen={isEditPopupOpen}
onClose={() => {
setIsEditPopupOpen(false);
}}
/>
<div className={styles.wrap}>
<Header />
<UpdateTokenTimer />
@ -185,10 +234,30 @@ const PartnerPage: React.FC = (): JSX.Element => {
<tr>
<td className={styles.clm0}>
<ul className={styles.menuInTable}>
{/* CCB
{isVisibleButton && (
<li>
<a>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
onClick={() => {
onOpenEditPopup(x);
}}
>
{t(
getTranslationID(
"partnerPage.label.editAccount"
)
)}
</a>
</li>
)}
{isVisibleButton && (
<li>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
onClick={() => {
onDeleteAccount(x.accountId, x.name);
}}
>
{t(
getTranslationID(
"partnerPage.label.deleteAccount"
@ -197,7 +266,6 @@ const PartnerPage: React.FC = (): JSX.Element => {
</a>
</li>
)}
*/}
{isVisibleDealerManagement && (
<li>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch } from "app/store";
import Header from "components/header";
@ -13,6 +13,7 @@ import {
selectTemplates,
listTemplateAsync,
selectIsLoading,
deleteTemplateAsync,
} from "features/workflow/template";
import { selectDelegationAccessToken } from "features/auth/selectors";
import { DelegationBar } from "components/delegate";
@ -35,6 +36,23 @@ export const TemplateFilePage: React.FC = () => {
dispatch(listTemplateAsync());
}, [dispatch]);
const onDeleteTemplate = useCallback(
async (templateFileId: number) => {
if (
/* eslint-disable-next-line no-alert */
!window.confirm(t(getTranslationID("common.message.dialogConfirm")))
) {
return;
}
const { meta } = await dispatch(deleteTemplateAsync({ templateFileId }));
if (meta.requestStatus === "fulfilled") {
dispatch(listTemplateAsync());
}
},
[dispatch, t]
);
return (
<>
{isShowAddPopup && (
@ -101,16 +119,17 @@ export const TemplateFilePage: React.FC = () => {
<td>{template.name}</td>
<td>
<ul className={`${styles.menuAction} ${styles.inTable}`}>
{/* CCB
<li>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<a
href=""
className={`${styles.menuLink} ${styles.isActive}`}
onClick={() => {
onDeleteTemplate(template.id);
}}
>
{t(getTranslationID("common.label.delete"))}
</a>
</li>
*/}
</ul>
</td>
</tr>

View File

@ -11,6 +11,7 @@ import {
selectTypistGroups,
selectIsLoading,
listTypistGroupsAsync,
deleteTypistGroupAsync,
} from "features/workflow/typistGroup";
import { AppDispatch } from "app/store";
import { useTranslation } from "react-i18next";
@ -47,6 +48,25 @@ const TypistGroupSettingPage: React.FC = (): JSX.Element => {
[setIsEditPopupOpen]
);
const onDeleteTypistGroup = useCallback(
async (typistGroupId: number) => {
if (
/* eslint-disable-next-line no-alert */
!window.confirm(t(getTranslationID("common.message.dialogConfirm")))
) {
return;
}
const { meta } = await dispatch(
deleteTypistGroupAsync({ typistGroupId })
);
if (meta.requestStatus === "fulfilled") {
dispatch(listTypistGroupsAsync());
}
},
[dispatch, t]
);
useEffect(() => {
dispatch(listTypistGroupsAsync());
}, [dispatch]);
@ -142,6 +162,17 @@ const TypistGroupSettingPage: React.FC = (): JSX.Element => {
{t(getTranslationID("common.label.edit"))}
</a>
</li>
<li>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
className={`${styles.menuLink} ${styles.isActive}`}
onClick={() => {
onDeleteTypistGroup(group.id);
}}
>
{t(getTranslationID("common.label.delete"))}
</a>
</li>
</ul>
</td>
</tr>

View File

@ -0,0 +1,258 @@
import { AppDispatch } from "app/store";
import React, { useState, useCallback } from "react";
import styles from "styles/app.module.scss";
import { useDispatch, useSelector } from "react-redux";
import { getTranslationID } from "translation";
import { useTranslation } from "react-i18next";
import {
selectIsLoading,
importUsersAsync,
changeImportFileName,
changeImportCsv,
selectImportFileName,
selectImportValidationErrors,
cleanupImportUsers,
} from "features/user";
import { parseCSV } from "common/parser";
import close from "../../assets/images/close.svg";
import download from "../../assets/images/download.svg";
import upload from "../../assets/images/upload.svg";
import progress_activit from "../../assets/images/progress_activit.svg";
interface UserAddPopupProps {
isOpen: boolean;
onClose: () => void;
}
export const ImportPopup: React.FC<UserAddPopupProps> = (props) => {
const { isOpen, onClose } = props;
const dispatch: AppDispatch = useDispatch();
const { t } = useTranslation();
// AddUserの情報を取得
const closePopup = useCallback(() => {
setIsPushImportButton(false);
dispatch(cleanupImportUsers());
onClose();
}, [onClose, dispatch]);
const [isPushImportButton, setIsPushImportButton] = useState<boolean>(false);
const isLoading = useSelector(selectIsLoading);
const importFileName = useSelector(selectImportFileName);
const { invalidInput, duplicatedEmails, duplicatedAuthorIds, overMaxRow } =
useSelector(selectImportValidationErrors);
const onDownloadCsv = useCallback(() => {
// csvファイルダウンロード処理
const filename = `import_users.csv`;
const importCsvHeader = [
"name",
"email",
"role",
"author_id",
"auto_assign",
"notification",
"encryption",
"encryption_password",
"prompt",
].toString();
const blob = new Blob([importCsvHeader], {
type: "mime",
});
const blobURL = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobURL;
a.download = filename;
document.body.appendChild(a);
a.click();
a.parentNode?.removeChild(a);
}, []);
// ファイルが選択されたときの処理
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
// 選択されたファイルを取得(複数選択されても先頭を取得)
const file = event.target.files?.[0];
// ファイルが選択されていれば、storeに保存
if (file) {
const text = await file.text();
const users = await parseCSV(text.trimEnd());
dispatch(changeImportCsv({ users }));
dispatch(changeImportFileName({ fileName: file.name }));
}
// 同名のファイルを選択した場合、onChangeが発火しないため、valueをクリアする
event.target.value = "";
},
[dispatch]
);
const onImportUsers = useCallback(async () => {
setIsPushImportButton(true);
if (
invalidInput.length > 0 ||
duplicatedEmails.length > 0 ||
duplicatedAuthorIds.length > 0 ||
overMaxRow
) {
return;
}
await dispatch(importUsersAsync());
setIsPushImportButton(false);
}, [
dispatch,
invalidInput,
duplicatedEmails,
duplicatedAuthorIds,
overMaxRow,
]);
return (
<div className={`${styles.modal} ${isOpen ? styles.isShow : ""}`}>
<div className={styles.modalBox}>
<p className={styles.modalTitle}>
{t(getTranslationID("userListPage.label.bulkImport"))}
<button type="button" onClick={closePopup}>
<img src={close} className={styles.modalTitleIcon} alt="close" />
</button>
</p>
<form className={styles.form}>
<dl
className={`${styles.formList} ${styles.userImport} ${styles.hasbg}`}
>
<dd className={styles.full}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
style={{ marginInlineEnd: "350px", marginTop: "15px" }}
className={`${styles.menuLink} ${styles.isActive}`}
onClick={onDownloadCsv}
>
<img src={download} alt="" className={styles.menuIcon} />
{t(getTranslationID("userListPage.label.downloadCsv"))}
</a>
{t(getTranslationID("userListPage.text.downloadExplain"))}
</dd>
<dd className={styles.full}>
<label
style={{ marginInlineEnd: "350px", marginTop: "15px" }}
htmlFor="import"
className={`${styles.menuLink} ${styles.isActive}`}
>
<input
type="file"
id="import"
style={{ display: "none" }}
onChange={handleFileChange}
/>
<img src={upload} alt="" className={styles.menuIcon} />
{t(getTranslationID("userListPage.label.importCsv"))}
</label>
</dd>
<dt className={styles.formTitle}>
{t(getTranslationID("userListPage.label.inputRules"))}
</dt>
<dt>{t(getTranslationID("userListPage.label.nameLabel"))}</dt>
<dd>{t(getTranslationID("userListPage.text.nameRule"))}</dd>
<dt>
{t(getTranslationID("userListPage.label.emailAddressLabel"))}
</dt>
<dd>{t(getTranslationID("userListPage.text.emailAddressRule"))}</dd>
<dt>{t(getTranslationID("userListPage.label.roleLabel"))}</dt>
<dd>{t(getTranslationID("userListPage.text.roleRule"))}</dd>
<dt>{t(getTranslationID("userListPage.label.authorIdLabel"))}</dt>
<dd>{t(getTranslationID("userListPage.text.authorIdRule"))}</dd>
<dt>{t(getTranslationID("userListPage.label.autoRenewLabel"))}</dt>
<dd>{t(getTranslationID("userListPage.text.autoRenewRule"))}</dd>
<dt>
{t(getTranslationID("userListPage.label.notificationLabel"))}
</dt>
<dd>{t(getTranslationID("userListPage.text.notificationRule"))}</dd>
<dt>{t(getTranslationID("userListPage.label.encryptionLabel"))}</dt>
<dd>{t(getTranslationID("userListPage.text.encryptionRule"))}</dd>
<dt>
{t(
getTranslationID("userListPage.label.encryptionPasswordLabel")
)}
</dt>
<dd>
{t(getTranslationID("userListPage.text.encryptionPasswordRule"))}
</dd>
<dt>{t(getTranslationID("userListPage.label.promptLabel"))}</dt>
<dd>{t(getTranslationID("userListPage.text.promptRule"))}</dd>
<dd className={styles.full}>
{isPushImportButton && overMaxRow && (
<span className={styles.formError}>
{t(getTranslationID("userListPage.message.overMaxUserError"))}
</span>
)}
{isPushImportButton && invalidInput.length > 0 && (
<>
<span className={styles.formError}>
{t(
getTranslationID("userListPage.message.invalidInputError")
)}
</span>
<span className={styles.formError}>
{invalidInput.map((row) => `L${row}`).join(", ")}
</span>
</>
)}
{isPushImportButton && duplicatedEmails.length > 0 && (
<>
<span className={styles.formError}>
{t(
getTranslationID(
"userListPage.message.duplicateEmailError"
)
)}
</span>
<span className={styles.formError}>
{duplicatedEmails.map((row) => `L${row}`).join(", ")}
</span>
</>
)}
{isPushImportButton && duplicatedAuthorIds.length > 0 && (
<>
<span className={styles.formError}>
{t(
getTranslationID(
"userListPage.message.duplicateAuthorIdError"
)
)}
</span>
<span className={styles.formError}>
{duplicatedAuthorIds.map((row) => `L${row}`).join(", ")}
</span>
</>
)}
</dd>
<dd className={`${styles.full} ${styles.alignCenter}`}>
<input
type="button"
name="submit"
value={t(getTranslationID("userListPage.label.addUsers"))}
className={`${styles.formSubmit} ${styles.marginBtm1} ${
!isLoading && importFileName !== undefined
? styles.isActive
: ""
}`}
onClick={onImportUsers}
/>
<img
style={{ display: isLoading ? "inline" : "none" }}
src={progress_activit}
className={styles.icLoading}
alt="Loading"
/>
</dd>
</dl>
</form>
</div>
</div>
);
};

View File

@ -10,6 +10,7 @@ import {
selectUserViews,
selectIsLoading,
deallocateLicenseAsync,
deleteUserAsync,
} from "features/user";
import { useTranslation } from "react-i18next";
import { getTranslationID } from "translation";
@ -31,9 +32,11 @@ import personAdd from "../../assets/images/person_add.svg";
import checkFill from "../../assets/images/check_fill.svg";
import checkOutline from "../../assets/images/check_outline.svg";
import progress_activit from "../../assets/images/progress_activit.svg";
import upload from "../../assets/images/upload.svg";
import { UserAddPopup } from "./popup";
import { UserUpdatePopup } from "./updatePopup";
import { AllocateLicensePopup } from "./allocateLicensePopup";
import { ImportPopup } from "./importPopup";
const UserListPage: React.FC = (): JSX.Element => {
const dispatch: AppDispatch = useDispatch();
@ -45,6 +48,7 @@ const UserListPage: React.FC = (): JSX.Element => {
const [isUpdatePopupOpen, setIsUpdatePopupOpen] = useState(false);
const [isAllocateLicensePopupOpen, setIsAllocateLicensePopupOpen] =
useState(false);
const [isImportPopupOpen, setIsImportPopupOpen] = useState(false);
const onOpen = useCallback(() => {
setIsPopupOpen(true);
@ -65,6 +69,9 @@ const UserListPage: React.FC = (): JSX.Element => {
},
[setIsAllocateLicensePopupOpen, dispatch]
);
const onImportPopupOpen = useCallback(() => {
setIsImportPopupOpen(true);
}, [setIsImportPopupOpen]);
const onLicenseDeallocation = useCallback(
async (userId: number) => {
@ -84,6 +91,24 @@ const UserListPage: React.FC = (): JSX.Element => {
[dispatch, t]
);
const onDeleteUser = useCallback(
async (userId: number) => {
// ダイアログ確認
if (
/* eslint-disable-next-line no-alert */
!window.confirm(t(getTranslationID("common.message.dialogConfirm")))
) {
return;
}
const { meta } = await dispatch(deleteUserAsync({ userId }));
if (meta.requestStatus === "fulfilled") {
dispatch(listUsersAsync());
}
},
[dispatch, t]
);
useEffect(() => {
// ユーザ一覧取得処理を呼び出す
dispatch(listUsersAsync());
@ -115,6 +140,12 @@ const UserListPage: React.FC = (): JSX.Element => {
setIsAllocateLicensePopupOpen(false);
}}
/>
<ImportPopup
isOpen={isImportPopupOpen}
onClose={() => {
setIsImportPopupOpen(false);
}}
/>
<div
className={`${styles.wrap} ${
delegationAccessToken ? styles.manage : ""
@ -146,6 +177,16 @@ const UserListPage: React.FC = (): JSX.Element => {
{t(getTranslationID("userListPage.label.addUser"))}
</a>
</li>
<li>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
className={`${styles.menuLink} ${styles.isActive}`}
onClick={onImportPopupOpen}
>
<img src={upload} alt="" className={styles.menuIcon} />
{t(getTranslationID("userListPage.label.bulkImport"))}
</a>
</li>
</ul>
<div className={styles.tableWrap}>
<table className={`${styles.table} ${styles.user}`}>
@ -255,9 +296,13 @@ const UserListPage: React.FC = (): JSX.Element => {
</li>
</>
)}
{/* CCB
<li>
<a href="">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
onClick={() => {
onDeleteUser(user.id);
}}
>
{t(
getTranslationID(
"userListPage.label.deleteUser"
@ -265,7 +310,6 @@ const UserListPage: React.FC = (): JSX.Element => {
)}
</a>
</li>
*/}
</ul>
</td>
<td> {user.name}</td>

View File

@ -15,11 +15,8 @@ const UserVerifyPage: React.FC = (): JSX.Element => {
const jwt = query.get("verify") ?? "";
useEffect(() => {
if (!jwt) {
navigate("/mail-confirm/failed");
}
dispatch(userVerifyAsync({ jwt }));
}, [navigate, dispatch, jwt]);
}, [dispatch, jwt]);
const verifyState = useSelector(VerifyStateSelector);

View File

@ -1630,6 +1630,43 @@ _:-ms-lang(x)::-ms-backdrop,
margin-bottom: 5rem;
}
.formList.userImport .formTitle {
padding: 1rem 4% 0;
line-height: 1.2;
}
.formList.userImport dt:not(.formTitle) {
width: 30%;
padding: 0 4% 0 4%;
font-size: 0.9rem;
}
.formList.userImport dt:not(.formTitle):nth-of-type(odd) {
background: #f0f0f0;
}
.formList.userImport dt:not(.formTitle):nth-of-type(odd) + dd {
background: #f0f0f0;
}
.formList.userImport dd {
width: 58%;
padding: 0.2rem 4% 0.2rem 0;
margin-bottom: 0;
white-space: pre-line;
word-wrap: break-word;
font-size: 0.9rem;
line-height: 1.2;
}
.formList.userImport dd.full {
width: 100%;
padding: 0.2rem 4% 0.2rem 4%;
}
.formList.userImport dd.full .buttonText {
padding: 0 0 0.8rem;
}
.formList.userImport dd .menuLink {
display: inline-block;
margin-bottom: 0.6rem;
padding: 0.5rem 1.5rem 0.5rem 1.3rem;
}
.account .listVertical {
margin-bottom: 3rem;
}
@ -1857,6 +1894,18 @@ tr.isSelected .menuInTable li a.isDisable {
cursor: pointer;
}
.license .checkAvail {
height: 30px;
padding: 0 0.3rem 0.3rem 0;
margin-top: -30px;
box-sizing: border-box;
}
.license .checkAvail label {
cursor: pointer;
}
.license .checkAvail label .formCheck {
vertical-align: middle;
}
.license .listVertical dd img[src*="circle"] {
filter: brightness(0) saturate(100%) invert(58%) sepia(41%) saturate(5814%)
hue-rotate(143deg) brightness(96%) contrast(101%);
@ -1950,6 +1999,61 @@ tr.isSelected .menuInTable li a.isDisable {
text-align: right;
}
.formList dd.ownerChange {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin-bottom: 0;
}
.formList dd.ownerChange p.Owner,
.formList dd.ownerChange p.newOwner {
width: 150px;
}
.formList dd.ownerChange .arrowR {
width: 8%;
height: 20px;
margin-top: 10px;
margin-right: 2%;
background: #e6e6e6;
position: relative;
}
.formList dd.ownerChange .arrowR::after {
content: "";
border-top: 20px transparent solid;
border-bottom: 20px transparent solid;
border-left: 20px #e6e6e6 solid;
position: absolute;
top: 50%;
right: -15px;
transform: translateY(-50%);
}
.formList dd.ownerChange + .full {
width: 66%;
margin-left: 30%;
margin-bottom: -10px;
text-align: center;
}
.formList dd.ownerChange + .full .transOwner {
width: 100px;
}
.formList dd.lowerTrans {
margin-bottom: 1.5rem;
position: relative;
text-align: center;
}
.formList dd.lowerTrans select,
.formList dd.lowerTrans span {
margin: 0 auto;
}
.formList dd .txName {
display: block;
width: 150px;
padding: 0.2rem 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dictation .menuAction {
margin-top: -1rem;
height: 34px;
@ -2272,6 +2376,9 @@ tr.isSelected .menuInTable li a.isDisable {
.formList.property dt:not(.formTitle):nth-of-type(odd) + dd {
background: #f0f0f0;
}
.formList.property dt:has(+ dd.hasInput) {
padding-top: 0.4rem;
}
.formList.property dd {
width: 58%;
padding: 0.2rem 4% 0.2rem 0;
@ -2283,6 +2390,16 @@ tr.isSelected .menuInTable li a.isDisable {
.formList.property dd img {
height: 1.1rem;
}
.formList.property dd .formInput.short {
width: 250px;
padding: 0.3rem 0.3rem 0.1rem;
}
.formList.property dd .formSubmit {
min-width: auto;
padding: 0.2rem 0.5rem;
position: absolute;
right: 0.5rem;
}
.formList.property dd.full {
width: 100%;
padding: 0.2rem 4% 0.2rem 4%;

View File

@ -108,11 +108,12 @@ declare const classNames: {
readonly clm0: "clm0";
readonly menuInTable: "menuInTable";
readonly isSelected: "isSelected";
readonly userImport: "userImport";
readonly menuLink: "menuLink";
readonly odd: "odd";
readonly alignRight: "alignRight";
readonly menuAction: "menuAction";
readonly inTable: "inTable";
readonly menuLink: "menuLink";
readonly menuIcon: "menuIcon";
readonly colorLink: "colorLink";
readonly isDisable: "isDisable";
@ -123,10 +124,18 @@ declare const classNames: {
readonly txNormal: "txNormal";
readonly manageIcon: "manageIcon";
readonly manageIconClose: "manageIconClose";
readonly checkAvail: "checkAvail";
readonly history: "history";
readonly cardHistory: "cardHistory";
readonly partner: "partner";
readonly isOpen: "isOpen";
readonly ownerChange: "ownerChange";
readonly Owner: "Owner";
readonly newOwner: "newOwner";
readonly arrowR: "arrowR";
readonly transOwner: "transOwner";
readonly lowerTrans: "lowerTrans";
readonly txName: "txName";
readonly alignLeft: "alignLeft";
readonly displayOptions: "displayOptions";
readonly tableFilter: "tableFilter";
@ -192,6 +201,7 @@ declare const classNames: {
readonly hideO10: "hideO10";
readonly op10: "op10";
readonly property: "property";
readonly hasInput: "hasInput";
readonly formChange: "formChange";
readonly chooseMember: "chooseMember";
readonly holdMember: "holdMember";

View File

@ -654,4 +654,4 @@
"lowerLayerId": "Lower Layer ID"
}
}
}
}

View File

@ -6,7 +6,7 @@ FROM node:18.17.1-buster
RUN /bin/cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
echo "Asia/Tokyo" > /etc/timezone
# Options for setup script
ARG INSTALL_ZSH="true"
ARG UPGRADE_PACKAGES="false"
@ -24,6 +24,21 @@ RUN bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "$
&& apt-get install default-jre -y \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts
# COPY --from=golang:1.18-buster /usr/local/go/ /usr/local/go/
ENV GO111MODULE=auto
COPY library-scripts/go-debian.sh /tmp/library-scripts/
RUN bash /tmp/library-scripts/go-debian.sh "1.18" "/usr/local/go" "${GOPATH}" "${USERNAME}" "false" \
&& apt-get clean -y && rm -rf /tmp/library-scripts
ENV PATH="/usr/local/go/bin:${PATH}"
RUN mkdir -p /tmp/gotools \
&& cd /tmp/gotools \
&& export GOPATH=/tmp/gotools \
&& export GOCACHE=/tmp/gotools/cache \
# sql-migrate
&& go install github.com/rubenv/sql-migrate/sql-migrate@v1.1.2 \
&& mv /tmp/gotools/bin/* ${TARGET_GOPATH}/bin/ \
&& rm -rf /tmp/gotools
# Update NPM
RUN npm install -g npm

View File

@ -16,6 +16,15 @@ services:
- CHOKIDAR_USEPOLLING=true
networks:
- external
test_mysql_db:
image: mysql:8.0-bullseye
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: odms
MYSQL_USER: user
MYSQL_PASSWORD: password
networks:
- external
networks:
external:
name: omds_network

View File

@ -0,0 +1,31 @@
version: "3"
services:
dictation_function:
build: .
working_dir: /app/dictation_function
volumes:
- ../../:/app
- node_modules:/app/dictation_function/node_modules
environment:
- CHOKIDAR_USEPOLLING=true
depends_on:
- test_mysql_db
networks:
- dictation_function_network
test_mysql_db:
image: mysql:8.0-bullseye
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: odms
MYSQL_USER: user
MYSQL_PASSWORD: password
networks:
- dictation_function_network
networks:
dictation_function_network:
name: test_dictation_function_network
# Data Volume として永続化する
volumes:
node_modules:

View File

@ -1,5 +1,6 @@
DB_HOST=omds-mysql
DB_PORT=3306
DB_NAME=omds
DB_NAME_CCB=omds_ccb
DB_USERNAME=omdsdbuser
DB_PASSWORD=omdsdbpass

View File

@ -0,0 +1,35 @@
STAGE=local
TENANT_NAME=tenantoname
SIGNIN_FLOW_NAME=b2c_1_signin_dev
ADB2C_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ADB2C_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ADB2C_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ADB2C_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ADB2C_ORIGIN=https://xxxxxxx.b2clogin.com/xxxxxxxx.onmicrosoft.com/b2c_1_signin_dev/
KEY_VAULT_NAME=xxxxxxxxxxxxxxx
SENDGRID_API_KEY=SG.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
MAIL_FROM=noreply@se0223.com
APP_DOMAIN=http://localhost:8081/
REDIS_HOST=redis-cache
REDIS_PORT=6379
REDIS_PASSWORD=omdsredispass
ADB2C_CACHE_TTL=86400
STORAGE_ACCOUNT_NAME_US=saxxxxxxxxx
STORAGE_ACCOUNT_NAME_AU=saxxxxxxxxx
STORAGE_ACCOUNT_NAME_EU=saxxxxxxxxx
STORAGE_ACCOUNT_NAME_IMPORT=saxxxxxxxxx
STORAGE_ACCOUNT_NAME_ANALYSIS=saxxxxxxxxx
STORAGE_ACCOUNT_KEY_US=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==
STORAGE_ACCOUNT_KEY_AU=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==
STORAGE_ACCOUNT_KEY_EU=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==
STORAGE_ACCOUNT_KEY_IMPORT=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==
STORAGE_ACCOUNT_KEY_ANALYSIS=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==
STORAGE_ACCOUNT_ENDPOINT_US=https://saxxxxxxxxx.blob.core.windows.net/
STORAGE_ACCOUNT_ENDPOINT_AU=https://saxxxxxxxxx.blob.core.windows.net/
STORAGE_ACCOUNT_ENDPOINT_EU=https://saxxxxxxxxx.blob.core.windows.net/
STORAGE_ACCOUNT_ENDPOINT_IMPORT=https://saxxxxxxxxx.blob.core.windows.net/
STORAGE_ACCOUNT_ENDPOINT_ANALYSIS=https://saxxxxxxxxx.blob.core.windows.net/
BASE_PATH=http://localhost:8081
ACCESS_TOKEN_LIFETIME_WEB=7200000
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA5IZZNgDew9eGmuFTezwdHYLSaJvUPPIKYoiOeVLD1paWNI51\n7Vkaoh0ngprcKOdv6T1N07V4igK7mOim2zY3yCTR6wcWR3PfFJrl9vh5SOo79koZ\noJb27YiM4jtxfx2dezzp0T2GoNR5rRolPUbWFJXnDe0DVXYXpJLb4LAlF2XAyYX0\nSYKUVUsJnzm5k4xbXtnwPwVbpm0EdswBE6qSfiL9zWk9dvHoKzSnfSDzDFoFcEoV\nchawzYXf/MM1YR4wo5XyzECc6Q5Ah4z522//mBNNaDHv83Yuw3mGShT73iJ0JQdk\nTturshv2Ecma38r6ftrIwNYXw4VVatJM8+GOOQIDAQABAoIBADrwp7u097+dK/tw\nWD61n3DIGAqg/lmFt8X4IH8MKLSE/FKr16CS1bqwOEuIM3ZdUtDeXd9Xs7IsyEPE\n5ZwuXK7DSF0M4+Mj8Ip49Q0Aww9aUoLQU9HGfgN/r4599GTrt31clZXA/6Mlighq\ncOZgCcEfdItz8OMu5SQuOIW4CKkCuaWnPOP26UqZocaXNZfpZH0iFLATMMH/TT8x\nay9ToHTQYE17ijdQ/EOLSwoeDV1CU1CIE3P4YfLJjvpKptly5dTevriHEzBi70Jx\n/KEPUn9Jj2gZafrUxRVhmMbm1zkeYxL3gsqRuTzRjEeeILuZhSJyCkQZyUNARxsg\nQY4DZfECgYEA+YLKUtmYTx60FS6DJ4s31TAsXY8kwhq/lB9E3GBZKDd0DPayXEeK\n4UWRQDTT6MI6fedW69FOZJ5sFLp8HQpcssb4Weq9PCpDhNTx8MCbdH3Um5QR3vfW\naKq/1XM8MDUnx5XcNYd87Aw3azvJAvOPr69as8IPnj6sKaRR9uQjbYUCgYEA6nfV\n5j0qmn0EJXZJblk4mvvjLLoWSs17j9YlrZJlJxXMDFRYtgnelv73xMxOMvcGoxn5\nifs7dpaM2x5EmA6jVU5sYaB/beZGEPWqPYGyjIwXPvUGAAv8Gbnvpp+xlSco/Dum\nIq0w+43ry5/xWh6CjfrvKV0J2bDOiJwPEdu/8iUCgYEAnBBSvL+dpN9vhFAzeOh7\nY71eAqcmNsLEUcG9MJqTKbSFwhYMOewF0iHRWHeylEPokhfBJn8kqYrtz4lVWFTC\n5o/Nh3BsLNXCpbMMIapXkeWiti1HgE9ErPMgSkJpwz18RDpYIqM8X+jEQS6D7HSr\nyxfDg+w+GJza0rEVE3hfMIECgYBw+KZ2VfhmEWBjEHhXE+QjQMR3s320MwebCUqE\nNCpKx8TWF/naVC0MwfLtvqbbBY0MHyLN6d//xpA9r3rLbRojqzKrY2KiuDYAS+3n\nzssRzxoQOozWju+8EYu30/ADdqfXyIHG6X3VZs87AGiQzGyJLmP3oR1y5y7MQa09\nJI16hQKBgHK5uwJhGa281Oo5/FwQ3uYLymbNwSGrsOJXiEu2XwJEXwVi2ELOKh4/\n03pBk3Kva3fIwEK+vCzDNnxShIQqBE76/2I1K1whOfoUehhYvKHGaXl2j70Zz9Ks\nrkGW1cx7p+yDqATDrwHBHTHFh5bUTTn8dN40n0e0W/llurpbBkJM\n-----END RSA PRIVATE KEY-----\n"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5IZZNgDew9eGmuFTezwd\nHYLSaJvUPPIKYoiOeVLD1paWNI517Vkaoh0ngprcKOdv6T1N07V4igK7mOim2zY3\nyCTR6wcWR3PfFJrl9vh5SOo79koZoJb27YiM4jtxfx2dezzp0T2GoNR5rRolPUbW\nFJXnDe0DVXYXpJLb4LAlF2XAyYX0SYKUVUsJnzm5k4xbXtnwPwVbpm0EdswBE6qS\nfiL9zWk9dvHoKzSnfSDzDFoFcEoVchawzYXf/MM1YR4wo5XyzECc6Q5Ah4z522//\nmBNNaDHv83Yuw3mGShT73iJ0JQdkTturshv2Ecma38r6ftrIwNYXw4VVatJM8+GO\nOQIDAQAB\n-----END PUBLIC KEY-----\n"

View File

@ -11,6 +11,8 @@ Publish
*.Cache
project.lock.json
.test/
/packages
/TestResults

View File

@ -0,0 +1,2 @@
npx openapi-generator-cli version-manager set 7.1.0
npx openapi-generator-cli generate -g typescript-axios -i /app/dictation_function/src/api/odms/openapi.json -o /app/dictation_function/src/api/

View File

@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.1.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -9,11 +9,14 @@
"clean": "rimraf dist",
"prestart": "npm run clean && npm run build",
"start": "func start",
"test": "jest"
"test": "tsc --noEmit && sql-migrate up -config=/app/dictation_server/db/dbconfig.yml -env=test && jest -w 1",
"typecheck": "tsc --noEmit",
"codegen": "sh codegen.sh"
},
"dependencies": {
"@azure/functions": "^4.0.0",
"@azure/identity": "^3.1.3",
"@azure/storage-blob": "^12.17.0",
"@microsoft/microsoft-graph-client": "^3.0.5",
"@sendgrid/mail": "^7.7.0",
"dotenv": "^16.0.3",
@ -22,11 +25,13 @@
"typeorm": "^0.3.10"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.9.0",
"@types/jest": "^27.5.0",
"@types/node": "18.x",
"@types/redis": "^2.8.13",
"@types/redis-mock": "^0.17.3",
"azure-functions-core-tools": "^4.x",
"base64url": "^3.0.1",
"jest": "^28.0.3",
"redis-mock": "^0.56.3",
"rimraf": "^5.0.0",

View File

@ -1,5 +1,5 @@
export type AdB2cResponse = {
'@odata.context': string;
"@odata.context": string;
value: AdB2cUser[];
};
export type AdB2cUser = {

4
dictation_function/src/api/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
wwwroot/*.js
node_modules
typings
dist

View File

@ -0,0 +1 @@
# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm

View File

@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@ -0,0 +1,8 @@
.gitignore
.npmignore
api.ts
base.ts
common.ts
configuration.ts
git_push.sh
index.ts

View File

@ -0,0 +1 @@
7.1.0

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,86 @@
/* tslint:disable */
/* eslint-disable */
/**
* ODMSOpenAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from './configuration';
// Some imports not used depending on template conditions
// @ts-ignore
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
export const BASE_PATH = "http://localhost".replace(/\/+$/, "");
/**
*
* @export
*/
export const COLLECTION_FORMATS = {
csv: ",",
ssv: " ",
tsv: "\t",
pipes: "|",
};
/**
*
* @export
* @interface RequestArgs
*/
export interface RequestArgs {
url: string;
options: AxiosRequestConfig;
}
/**
*
* @export
* @class BaseAPI
*/
export class BaseAPI {
protected configuration: Configuration | undefined;
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
if (configuration) {
this.configuration = configuration;
this.basePath = configuration.basePath ?? basePath;
}
}
};
/**
*
* @export
* @class RequiredError
* @extends {Error}
*/
export class RequiredError extends Error {
constructor(public field: string, msg?: string) {
super(msg);
this.name = "RequiredError"
}
}
interface ServerMap {
[key: string]: {
url: string,
description: string,
}[];
}
/**
*
* @export
*/
export const operationServerMap: ServerMap = {
}

View File

@ -0,0 +1,150 @@
/* tslint:disable */
/* eslint-disable */
/**
* ODMSOpenAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "./configuration";
import type { RequestArgs } from "./base";
import type { AxiosInstance, AxiosResponse } from 'axios';
import { RequiredError } from "./base";
/**
*
* @export
*/
export const DUMMY_BASE_URL = 'https://example.com'
/**
*
* @throws {RequiredError}
* @export
*/
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
if (paramValue === null || paramValue === undefined) {
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
}
}
/**
*
* @export
*/
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
if (configuration && configuration.apiKey) {
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
? await configuration.apiKey(keyParamName)
: await configuration.apiKey;
object[keyParamName] = localVarApiKeyValue;
}
}
/**
*
* @export
*/
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
if (configuration && (configuration.username || configuration.password)) {
object["auth"] = { username: configuration.username, password: configuration.password };
}
}
/**
*
* @export
*/
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const accessToken = typeof configuration.accessToken === 'function'
? await configuration.accessToken()
: await configuration.accessToken;
object["Authorization"] = "Bearer " + accessToken;
}
}
/**
*
* @export
*/
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
? await configuration.accessToken(name, scopes)
: await configuration.accessToken;
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
}
}
function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void {
if (parameter == null) return;
if (typeof parameter === "object") {
if (Array.isArray(parameter)) {
(parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key));
}
else {
Object.keys(parameter).forEach(currentKey =>
setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`)
);
}
}
else {
if (urlSearchParams.has(key)) {
urlSearchParams.append(key, parameter);
}
else {
urlSearchParams.set(key, parameter);
}
}
}
/**
*
* @export
*/
export const setSearchParams = function (url: URL, ...objects: any[]) {
const searchParams = new URLSearchParams(url.search);
setFlattenedQueryParams(searchParams, objects);
url.search = searchParams.toString();
}
/**
*
* @export
*/
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
const nonString = typeof value !== 'string';
const needsSerialization = nonString && configuration && configuration.isJsonMime
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
: nonString;
return needsSerialization
? JSON.stringify(value !== undefined ? value : {})
: (value || "");
}
/**
*
* @export
*/
export const toPathString = function (url: URL) {
return url.pathname + url.search + url.hash
}
/**
*
* @export
*/
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = {...axiosArgs.options, url: (configuration?.basePath || axios.defaults.baseURL || basePath) + axiosArgs.url};
return axios.request<T, R>(axiosRequestArgs);
};
}

View File

@ -0,0 +1,110 @@
/* tslint:disable */
/* eslint-disable */
/**
* ODMSOpenAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface ConfigurationParameters {
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
username?: string;
password?: string;
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
basePath?: string;
serverIndex?: number;
baseOptions?: any;
formDataCtor?: new () => any;
}
export class Configuration {
/**
* parameter for apiKey security
* @param name security name
* @memberof Configuration
*/
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
username?: string;
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
password?: string;
/**
* parameter for oauth2 security
* @param name security name
* @param scopes oauth2 scope
* @memberof Configuration
*/
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
/**
* override base path
*
* @type {string}
* @memberof Configuration
*/
basePath?: string;
/**
* override server index
*
* @type {number}
* @memberof Configuration
*/
serverIndex?: number;
/**
* base options for axios calls
*
* @type {any}
* @memberof Configuration
*/
baseOptions?: any;
/**
* The FormData constructor that will be used to create multipart form data
* requests. You can inject this here so that execution environments that
* do not support the FormData class can still run the generated client.
*
* @type {new () => FormData}
*/
formDataCtor?: new () => any;
constructor(param: ConfigurationParameters = {}) {
this.apiKey = param.apiKey;
this.username = param.username;
this.password = param.password;
this.accessToken = param.accessToken;
this.basePath = param.basePath;
this.serverIndex = param.serverIndex;
this.baseOptions = param.baseOptions;
this.formDataCtor = param.formDataCtor;
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i');
return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json');
}
}

View File

@ -0,0 +1,57 @@
#!/bin/sh
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
#
# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com"
git_user_id=$1
git_repo_id=$2
release_note=$3
git_host=$4
if [ "$git_host" = "" ]; then
git_host="github.com"
echo "[INFO] No command line input provided. Set \$git_host to $git_host"
fi
if [ "$git_user_id" = "" ]; then
git_user_id="GIT_USER_ID"
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
fi
if [ "$git_repo_id" = "" ]; then
git_repo_id="GIT_REPO_ID"
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
fi
if [ "$release_note" = "" ]; then
release_note="Minor update"
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
fi
# Initialize the local directory as a Git repository
git init
# Adds the files in the local repository and stages them for commit.
git add .
# Commits the tracked changes and prepares them to be pushed to a remote repository.
git commit -m "$release_note"
# Sets the new remote
git_remote=$(git remote)
if [ "$git_remote" = "" ]; then # git remote not defined
if [ "$GIT_TOKEN" = "" ]; then
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
else
git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
fi
fi
git pull origin master
# Pushes (Forces) the changes in the local repository up to the remote repository
echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git"
git push origin master 2>&1 | grep -v 'To https'

View File

@ -0,0 +1,18 @@
/* tslint:disable */
/* eslint-disable */
/**
* ODMSOpenAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export * from "./api";
export * from "./configuration";

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,146 @@
import {
BlobServiceClient,
ContainerClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
import {
BLOB_STORAGE_REGION_AU,
BLOB_STORAGE_REGION_EU,
BLOB_STORAGE_REGION_US,
} from "../constants";
import { InvocationContext } from "@azure/functions";
export class AudioBlobStorageService {
private readonly sharedKeyCredentialUS: StorageSharedKeyCredential;
private readonly sharedKeyCredentialEU: StorageSharedKeyCredential;
private readonly sharedKeyCredentialAU: StorageSharedKeyCredential;
private readonly blobServiceClientUS: BlobServiceClient;
private readonly blobServiceClientEU: BlobServiceClient;
private readonly blobServiceClientAU: BlobServiceClient;
constructor() {
if (
!process.env.STORAGE_ACCOUNT_ENDPOINT_US ||
!process.env.STORAGE_ACCOUNT_ENDPOINT_AU ||
!process.env.STORAGE_ACCOUNT_ENDPOINT_EU ||
!process.env.STORAGE_ACCOUNT_NAME_US ||
!process.env.STORAGE_ACCOUNT_KEY_US ||
!process.env.STORAGE_ACCOUNT_NAME_AU ||
!process.env.STORAGE_ACCOUNT_KEY_AU ||
!process.env.STORAGE_ACCOUNT_NAME_EU ||
!process.env.STORAGE_ACCOUNT_KEY_EU
) {
throw new Error("Storage account information is missing");
}
// リージョンごとのSharedKeyCredentialを生成
this.sharedKeyCredentialUS = new StorageSharedKeyCredential(
process.env.STORAGE_ACCOUNT_NAME_US,
process.env.STORAGE_ACCOUNT_KEY_US
);
this.sharedKeyCredentialAU = new StorageSharedKeyCredential(
process.env.STORAGE_ACCOUNT_NAME_AU,
process.env.STORAGE_ACCOUNT_KEY_AU
);
this.sharedKeyCredentialEU = new StorageSharedKeyCredential(
process.env.STORAGE_ACCOUNT_NAME_EU,
process.env.STORAGE_ACCOUNT_KEY_EU
);
// Audioファイルの保存先のリージョンごとにBlobServiceClientを生成
this.blobServiceClientUS = new BlobServiceClient(
process.env.STORAGE_ACCOUNT_ENDPOINT_US,
this.sharedKeyCredentialUS
);
this.blobServiceClientAU = new BlobServiceClient(
process.env.STORAGE_ACCOUNT_ENDPOINT_AU,
this.sharedKeyCredentialAU
);
this.blobServiceClientEU = new BlobServiceClient(
process.env.STORAGE_ACCOUNT_ENDPOINT_EU,
this.sharedKeyCredentialEU
);
}
/**
*
* @param context
* @param accountId
* @param country
* @param fileName
* @returns file
*/
async deleteFile(
context: InvocationContext,
accountId: number,
country: string,
fileName: string
): Promise<void> {
context.log(
`[IN] ${this.deleteFile.name} | params: { ` +
`accountId: ${accountId} ` +
`country: ${country} ` +
`fileName: ${fileName} };`
);
try {
// 国に応じたリージョンでコンテナ名を指定してClientを取得
const containerClient = this.getContainerClient(
context,
accountId,
country
);
// コンテナ内のBlobパス名を指定してClientを取得
const blobClient = containerClient.getBlobClient(fileName);
const { succeeded, errorCode, date } = await blobClient.deleteIfExists();
context.log(
`succeeded: ${succeeded}, errorCode: ${errorCode}, date: ${date}`
);
// 失敗時、Blobが存在しない場合以外はエラーとして例外をスローする
// Blob不在の場合のエラーコードは「BlobNotFound」以下を参照
// https://learn.microsoft.com/ja-jp/rest/api/storageservices/blob-service-error-codes
if (!succeeded && errorCode !== "BlobNotFound") {
throw new Error(
`delete blob failed. succeeded: ${succeeded}, errorCode: ${errorCode}, date: ${date}`
);
}
} catch (e) {
context.error(`error=${e}`);
throw e;
} finally {
context.log(`[OUT] ${this.deleteFile.name}`);
}
}
/**
* IDと国に応じたリージョンのコンテナクライアントを取得します
* @param context
* @param accountId
* @param country
* @returns
*/
private getContainerClient(
context: InvocationContext,
accountId: number,
country: string
): ContainerClient {
context.log(
`[IN] ${this.getContainerClient.name} | params: { ` +
`accountId: ${accountId}; country: ${country} };`
);
const containerName = `account-${accountId}`;
if (BLOB_STORAGE_REGION_US.includes(country)) {
return this.blobServiceClientUS.getContainerClient(containerName);
} else if (BLOB_STORAGE_REGION_AU.includes(country)) {
return this.blobServiceClientAU.getContainerClient(containerName);
} else if (BLOB_STORAGE_REGION_EU.includes(country)) {
return this.blobServiceClientEU.getContainerClient(containerName);
} else {
throw new Error("invalid country");
}
}
}

View File

@ -0,0 +1,269 @@
import {
BlobServiceClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
import {
IMPORT_USERS_CONTAINER_NAME,
LICENSE_COUNT_ANALYSIS_CONTAINER_NAME,
} from "../constants";
import { InvocationContext } from "@azure/functions";
export class BlobstorageService {
private readonly blobServiceClient: BlobServiceClient;
private readonly sharedKeyCredential: StorageSharedKeyCredential;
constructor(useAnalysisBlob: boolean = false) {
if (!useAnalysisBlob) {
if (
!process.env.STORAGE_ACCOUNT_NAME_IMPORT ||
!process.env.STORAGE_ACCOUNT_KEY_IMPORT ||
!process.env.STORAGE_ACCOUNT_ENDPOINT_IMPORT
) {
throw new Error("Storage account information is missing");
}
this.sharedKeyCredential = new StorageSharedKeyCredential(
process.env.STORAGE_ACCOUNT_NAME_IMPORT,
process.env.STORAGE_ACCOUNT_KEY_IMPORT
);
this.blobServiceClient = new BlobServiceClient(
process.env.STORAGE_ACCOUNT_ENDPOINT_IMPORT,
this.sharedKeyCredential
);
} else {
if (
!process.env.STORAGE_ACCOUNT_NAME_ANALYSIS ||
!process.env.STORAGE_ACCOUNT_KEY_ANALYSIS ||
!process.env.STORAGE_ACCOUNT_ENDPOINT_ANALYSIS
) {
throw new Error("Storage account information for analysis is missing");
}
this.sharedKeyCredential = new StorageSharedKeyCredential(
process.env.STORAGE_ACCOUNT_NAME_ANALYSIS,
process.env.STORAGE_ACCOUNT_KEY_ANALYSIS
);
this.blobServiceClient = new BlobServiceClient(
process.env.STORAGE_ACCOUNT_ENDPOINT_ANALYSIS,
this.sharedKeyCredential
);
}
}
/**
* Lists blobs
* @returns blobs
*/
async listBlobs(context: InvocationContext): Promise<string[]> {
context.log(`[IN] ${this.listBlobs.name}`);
try {
const containerClient = this.blobServiceClient.getContainerClient(
IMPORT_USERS_CONTAINER_NAME
);
const blobNames: string[] = [];
// stage.json以外のファイルを取得
for await (const blob of containerClient.listBlobsFlat({
prefix: "U_",
})) {
blobNames.push(blob.name);
}
return blobNames;
} catch (error) {
context.error(error);
throw error;
} finally {
context.log(`[OUT] ${this.listBlobs.name}`);
}
}
/**
* Downloads a blob
* @param context
* @param filename
* @returns file buffer
*/
public async downloadFileData(
context: InvocationContext,
filename: string
): Promise<string | undefined> {
context.log(
`[IN] ${this.downloadFileData.name} | params: { filename: ${filename} }`
);
try {
const containerClient = this.blobServiceClient.getContainerClient(
IMPORT_USERS_CONTAINER_NAME
);
const blobClient = containerClient.getBlobClient(filename);
try {
const downloadBlockBlobResponse = await blobClient.downloadToBuffer();
return downloadBlockBlobResponse.toString();
} catch (error) {
// ファイルが存在しない場合はundefinedを返す
if (error?.statusCode === 404) {
return undefined;
}
throw error;
}
} catch (error) {
context.error(error);
throw error;
} finally {
context.log(`[OUT] ${this.downloadFileData.name}`);
}
}
/**
* Updates file
* @param context
* @param filename
* @param data
* @returns file
*/
public async updateFile(
context: InvocationContext,
filename: string,
data: string
): Promise<boolean> {
context.log(
`[IN] ${this.updateFile.name} | params: { filename: ${filename} }`
);
try {
const containerClient = this.blobServiceClient.getContainerClient(
IMPORT_USERS_CONTAINER_NAME
);
const { response } = await containerClient.uploadBlockBlob(
filename,
data,
data.length
);
if (response.errorCode) {
context.log(`update failed. response errorCode: ${response.errorCode}`);
return false;
}
return true;
} catch (error) {
context.error(error);
throw error;
} finally {
context.log(`[OUT] ${this.updateFile.name}`);
}
}
/**
* Deletes file
* @param context
* @param filename
* @returns file
*/
public async deleteFile(
context: InvocationContext,
filename: string
): Promise<void> {
context.log(
`[IN] ${this.deleteFile.name} | params: { filename: ${filename} }`
);
try {
const containerClient = this.blobServiceClient.getContainerClient(
IMPORT_USERS_CONTAINER_NAME
);
const blobClient = containerClient.getBlobClient(filename);
await blobClient.deleteIfExists();
} catch (error) {
context.error(error);
throw error;
} finally {
context.log(`[OUT] ${this.deleteFile.name}`);
}
}
/**
* Determines whether file exists is
* @param context
* @param filename
* @returns file exists
*/
public async isFileExists(
context: InvocationContext,
filename: string
): Promise<boolean> {
context.log(
`[IN] ${this.isFileExists.name} | params: { filename: ${filename} }`
);
try {
const containerClient = this.blobServiceClient.getContainerClient(
IMPORT_USERS_CONTAINER_NAME
);
const blobClient = containerClient.getBlobClient(filename);
return await blobClient.exists();
} catch (error) {
context.error(error);
throw error;
} finally {
context.log(`[OUT] ${this.isFileExists.name}`);
}
}
/**
* uplocad file analysis licenses csv
* @param context
* @param filename
* @param data
* @returns boolean
*/
public async uploadFileAnalysisLicensesCSV(
context: InvocationContext,
filename: string,
data: string
): Promise<boolean> {
context.log(
`[IN] ${this.uploadFileAnalysisLicensesCSV.name} | params: { filename: ${filename} }`
);
try {
const containerClient = this.blobServiceClient.getContainerClient(
LICENSE_COUNT_ANALYSIS_CONTAINER_NAME
);
const { response } = await containerClient.uploadBlockBlob(
filename,
data,
data.length
);
if (response.errorCode) {
context.log(`update failed. response errorCode: ${response.errorCode}`);
return false;
}
return true;
} catch (e) {
context.error(e);
throw e;
} finally {
context.log(`[OUT] ${this.uploadFileAnalysisLicensesCSV.name}`);
}
}
/**
* Create container analysis
* @param context
* @returns container
*/
async createContainerAnalysis(context: InvocationContext): Promise<void> {
context.log(`[IN] ${this.createContainerAnalysis.name}`);
try {
// コンテナ作成
const containerClient = this.blobServiceClient.getContainerClient(
LICENSE_COUNT_ANALYSIS_CONTAINER_NAME
);
// コンテナが存在しない場合のみ作成
if (await containerClient.exists()) {
return;
}
await containerClient.create();
} catch (e) {
context.error(e);
throw e;
} finally {
context.log(`[OUT] ${this.createContainerAnalysis.name}`);
}
}
}

View File

@ -0,0 +1,165 @@
import { InvocationContext } from "@azure/functions";
import { IMPORT_USERS_STAGES } from "../../constants";
import { ErrorRow, ImportData, ImportJson, StageJson } from "./types";
const isErrorRow = (obj: any): obj is ErrorRow => {
if (typeof obj !== "object") return false;
const errorRow = obj as ErrorRow;
if (errorRow.name === undefined || typeof errorRow.name !== "string") {
return false;
}
if (errorRow.row === undefined || typeof errorRow.row !== "number") {
return false;
}
if (errorRow.error === undefined || typeof errorRow.error !== "string") {
return false;
}
return true;
};
export const isStageJson = (obj: any): obj is StageJson => {
if (typeof obj !== "object") return false;
const stageJson = obj as StageJson;
if (
stageJson.filename !== undefined &&
typeof stageJson.filename !== "string"
) {
return false;
}
if (stageJson.update === undefined || typeof stageJson.update !== "number") {
return false;
}
if (stageJson.row !== undefined && typeof stageJson.row !== "number") {
return false;
}
if (
stageJson.errors !== undefined &&
(!Array.isArray(stageJson.errors) ||
!stageJson.errors.every((x) => isErrorRow(x)))
) {
return false;
}
if (
stageJson.state === undefined ||
!Object.values(IMPORT_USERS_STAGES).includes(stageJson.state)
) {
return false;
}
return true;
};
const isImportData = (
context: InvocationContext,
obj: any
): obj is ImportData => {
if (typeof obj !== "object") return false;
const importData = obj as ImportData;
if (importData.name === undefined || typeof importData.name !== "string") {
context.log("name is missing or not a string");
return false;
}
if (importData.email === undefined || typeof importData.email !== "string") {
context.log("email is missing or not a string");
return false;
}
if (importData.role === undefined || typeof importData.role !== "number") {
context.log("role is missing or not a number");
return false;
}
if (
importData.author_id !== undefined &&
typeof importData.author_id !== "string"
) {
context.log("author_id is missing or not a string");
return false;
}
if (
importData.auto_renew === undefined ||
typeof importData.auto_renew !== "number"
) {
context.log("auto_renew is missing or not a number");
return false;
}
if (
importData.notification === undefined ||
typeof importData.notification !== "number"
) {
context.log("notification is missing or not a number");
return false;
}
if (
importData.encryption !== undefined &&
typeof importData.encryption !== "number"
) {
context.log("encryption is missing or not a number");
return false;
}
if (
importData.encryption_password !== undefined &&
typeof importData.encryption_password !== "string"
) {
context.log("encryption_password is missing or not a string");
return false;
}
if (
importData.prompt !== undefined &&
typeof importData.prompt !== "number"
) {
context.log("prompt is missing or not a number");
return false;
}
return true;
};
export const isImportJson = (
context: InvocationContext,
obj: any
): obj is ImportJson => {
if (typeof obj !== "object") return false;
const importJson = obj as ImportJson;
if (
importJson.account_id === undefined ||
typeof importJson.account_id !== "number"
) {
context.log("account_id is missing or not a number");
return false;
}
if (
importJson.user_id === undefined ||
typeof importJson.user_id !== "number"
) {
context.log("user_id is missing or not a number");
return false;
}
if (
importJson.user_role === undefined ||
typeof importJson.user_role !== "string"
) {
context.log("user_role is missing or not a string");
return false;
}
if (
importJson.external_id === undefined ||
typeof importJson.external_id !== "string"
) {
context.log("external_id is missing or not a string");
return false;
}
if (
importJson.file_name === undefined ||
typeof importJson.file_name !== "string"
) {
context.log("file_name is missing or not a string");
return false;
}
if (
importJson.data === undefined ||
!Array.isArray(importJson.data) ||
!importJson.data.every((x) => isImportData(context, x))
) {
context.log("data is missing or not an array of ImportData");
return false;
}
return true;
};

View File

@ -0,0 +1,54 @@
import { IMPORT_USERS_STAGES, USER_ROLES } from "../../constants";
export type StageType =
(typeof IMPORT_USERS_STAGES)[keyof typeof IMPORT_USERS_STAGES];
export type StageJson = {
filename?: string | undefined;
update: number;
row?: number | undefined;
errors?: ErrorRow[] | undefined;
state: StageType;
};
export type ErrorRow = {
name: string;
row: number;
error: string;
};
export type ImportJson = {
account_id: number;
user_id: number;
user_role: RoleType;
external_id: string;
file_name: string;
date: number;
data: ImportData[];
};
export type ImportData = {
name: string;
email: string;
role: number;
author_id?: string | undefined;
auto_renew: number;
notification: number;
encryption?: number | undefined;
encryption_password?: string | undefined;
prompt?: number | undefined;
};
export type RoleType = (typeof USER_ROLES)[keyof typeof USER_ROLES];
export type User = {
name: string;
role: RoleType;
email: string;
autoRenew: boolean;
notification: boolean;
authorId?: string | undefined;
encryption?: boolean | undefined;
encryptionPassword?: string | undefined;
prompt?: boolean | undefined;
};

View File

@ -0,0 +1,79 @@
/*
E+6
- 1~2...
- 3~4DB...
- 5~6
ex)
E00XXXX : システムエラーDB接続失敗など
E01XXXX : 業務エラー
EXX00XX : 内部エラー
EXX01XX : トークンエラー
EXX02XX : DBエラーDB関連
EXX03XX : ADB2CエラーDB関連
*/
export const errorCodes = [
"E009999", // 汎用エラー
"E000101", // トークン形式不正エラー
"E000102", // トークン有効期限切れエラー
"E000103", // トークン非アクティブエラー
"E000104", // トークン署名エラー
"E000105", // トークン発行元エラー
"E000106", // トークンアルゴリズムエラー
"E000107", // トークン不足エラー
"E000108", // トークン権限エラー
"E000301", // ADB2Cへのリクエスト上限超過エラー
"E010001", // パラメータ形式不正エラー
"E010201", // 未認証ユーザエラー
"E010202", // 認証済ユーザエラー
"E010203", // 管理ユーザ権限エラー
"E010204", // ユーザ不在エラー
"E010205", // DBのRoleが想定外の値エラー
"E010206", // DBのTierが想定外の値エラー
"E010207", // ユーザーのRole変更不可エラー
"E010208", // ユーザーの暗号化パスワード不足エラー
"E010209", // ユーザーの同意済み利用規約バージョンが最新でないエラー
"E010301", // メールアドレス登録済みエラー
"E010302", // authorId重複エラー
"E010401", // PONumber重複エラー
"E010501", // アカウント不在エラー
"E010502", // アカウント情報変更不可エラー
"E010503", // 代行操作不許可エラー
"E010601", // タスク変更不可エラー(タスクが変更できる状態でない、またはタスクが存在しない)
"E010602", // タスク変更権限不足エラー
"E010603", // タスク不在エラー
"E010701", // Blobファイル不在エラー
"E010801", // ライセンス不在エラー
"E010802", // ライセンス取り込み済みエラー
"E010803", // ライセンス発行済みエラー
"E010804", // ライセンス数不足エラー
"E010805", // ライセンス有効期限切れエラー
"E010806", // ライセンス割り当て不可エラー
"E010807", // ライセンス割り当て解除不可エラー
"E010808", // ライセンス注文キャンセル不可エラー
"E010809", // ライセンス発行キャンセル不可エラー(ステータスが変えられている場合)
"E010810", // ライセンス発行キャンセル不可エラー(発行から一定期間経過した場合)
"E010811", // ライセンス発行キャンセル不可エラー(発行したライセンスが割り当てされている場合)
"E010908", // タイピストグループ不在エラー
"E010909", // タイピストグループ名重複エラー
"E011001", // ワークタイプ重複エラー
"E011002", // ワークタイプ登録上限超過エラー
"E011003", // ワークタイプ不在エラー
"E011004", // ワークタイプ使用中エラー
"E013001", // ワークフローのAuthorIDとWorktypeIDのペア重複エラー
"E013002", // ワークフロー不在エラー
"E014001", // ユーザー削除エラー(削除しようとしたユーザーがすでに削除済みだった)
"E014002", // ユーザー削除エラー(削除しようとしたユーザーが管理者だった)
"E014003", // ユーザー削除エラー削除しようとしたAuthorのAuthorIDがWorkflowに指定されていた
"E014004", // ユーザー削除エラー削除しようとしたTypistがWorkflowのTypist候補として指定されていた
"E014005", // ユーザー削除エラー削除しようとしたTypistがUserGroupに所属していた
"E014006", // ユーザー削除エラー(削除しようとしたユーザが所有者の未完了のタスクが残っている)
"E014007", // ユーザー削除エラー(削除しようとしたユーザーが有効なライセンスを持っていた)
"E014009", // ユーザー削除エラー削除しようとしたTypistが未完了のタスクのルーティングに設定されている
"E015001", // タイピストグループ削除済みエラー
"E015002", // タイピストグループがワークフローに紐づいているエラー
"E015003", // タイピストグループがルーティングされているエラー
"E016001", // テンプレートファイル削除エラー(削除しようとしたテンプレートファイルがすでに削除済みだった)
"E016002", // テンプレートファイル削除エラー削除しようとしたテンプレートファイルがWorkflowに指定されていた
"E016003", // テンプレートファイル削除エラー(削除しようとしたテンプレートファイルが未完了のタスクに紐づいていた)
] as const;

View File

@ -0,0 +1,3 @@
export * from "./code";
export * from "./types";
export * from "./utils";

View File

@ -0,0 +1,9 @@
import { errorCodes } from "./code";
export type ErrorObject = {
message: string;
code: ErrorCodeType;
statusCode?: number;
};
export type ErrorCodeType = typeof errorCodes[number];

View File

@ -0,0 +1,101 @@
import { AxiosError } from "axios";
import { isError } from "lodash";
import { ErrorResponse } from "../../api";
import { errorCodes } from "./code";
import { ErrorCodeType, ErrorObject } from "./types";
export const createErrorObject = (error: unknown): ErrorObject => {
// 最低限通常のエラーかを判定
// Error以外のものがthrowされた場合
// 基本的にないはずだがプログラム上あるので拾う
if (!isError(error)) {
return {
message: "not error type.",
code: "E009999",
};
}
// Axiosエラー 通信してのエラーであるかを判定
if (!isAxiosError(error)) {
return {
message: "not axios error.",
code: "E009999",
};
}
const errorResponse = error.response;
if (!errorResponse) {
return {
message: error.message,
code: "E009999",
statusCode: errorResponse,
};
}
const { data } = errorResponse;
// 想定しているエラーレスポンスの型か判定
if (!isErrorResponse(data)) {
return {
message: error.message,
code: "E009999",
statusCode: errorResponse.status,
};
}
const { message, code } = data;
// 想定しているエラーコードかを判定
if (!isErrorCode(code)) {
return {
message,
code: "E009999",
statusCode: errorResponse.status,
};
}
return {
message,
code,
statusCode: errorResponse.status,
};
};
const isAxiosError = (e: unknown): e is AxiosError => {
const error = e as AxiosError;
return error?.isAxiosError ?? false;
};
const isErrorResponse = (error: unknown): error is ErrorResponse => {
const errorResponse = error as ErrorResponse;
if (
errorResponse === undefined ||
errorResponse.message === undefined ||
errorResponse.code === undefined
) {
return false;
}
return true;
};
const isErrorCode = (errorCode: string): errorCode is ErrorCodeType =>
errorCodes.includes(errorCode as ErrorCodeType);
export const isErrorObject = (
data: unknown
): data is { error: ErrorObject } => {
if (
data &&
typeof data === "object" &&
"error" in data &&
typeof (data as { error: ErrorObject }).error === "object" &&
typeof (data as { error: ErrorObject }).error.message === "string" &&
typeof (data as { error: ErrorObject }).error.code === "string" &&
(typeof (data as { error: ErrorObject }).error.statusCode === "number" ||
(data as { error: ErrorObject }).error.statusCode === undefined)
) {
return true;
}
return false;
};

View File

@ -0,0 +1,3 @@
import { isVerifyError, sign, verify, decode, getJwtKey } from "./jwt";
export { isVerifyError, sign, verify, decode, getJwtKey };

View File

@ -0,0 +1,250 @@
import { sign, verify, isVerifyError } from "./jwt";
import base64url from "base64url";
test("success sign and verify", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
const payload = verify<{ value: "testvalue" }>(token, publicKey);
if (isVerifyError(payload)) {
throw new Error(`${payload.reason} | ${payload.message}`);
}
expect(payload.value).toBe("testvalue");
});
test("failed sign and verify (jwt expired)", () => {
// 有効期限を0秒にすることで、検証を行った時点で有効期限切れにする
const token = sign({ value: "testvalue" }, 0, privateKey);
const payload = verify<{ value: "testvalue" }>(token, publicKey);
if (!isVerifyError(payload)) {
throw new Error(JSON.stringify(payload));
}
expect(payload.reason).toBe("ExpiredError");
});
test("failed sign and verify (invalid key pair)", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
// 秘密鍵と対ではない公開鍵を使用して検証する
const payload = verify<{ value: "testvalue" }>(token, anotherPublicKey);
if (!isVerifyError(payload)) {
throw new Error(JSON.stringify(payload));
}
expect(payload.reason).toBe("InvalidToken");
expect(payload.message).toBe("invalid signature");
});
test("failed sign and verify (invalid public key)", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
// 公開鍵の形式になっていない文字列を使用して検証する
const payload = verify<{ value: "testvalue" }>(token, fakePublicKey);
if (!isVerifyError(payload)) {
throw new Error(JSON.stringify(payload));
}
expect(payload.reason).toBe("InvalidToken");
expect(payload.message).toBe(
"secretOrPublicKey must be an asymmetric key when using RS256"
);
});
test("failed sign (invalid private key)", () => {
expect(() => {
// 不正な秘密鍵で署名しようとする場合はエラーがthrowされる
sign({ value: "testvalue" }, 5 * 60, fakePrivateKey);
}).toThrowError();
});
test("success rewrite-token verify (as is)", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
const { header, payload, verifySignature } = splitToken(token);
{
// 何も操作せずに構築しなおした場合、成功する
const validToken = rebuildToken(header, payload, verifySignature);
const value = verify<{ value: string }>(validToken, publicKey);
if (isVerifyError(value)) {
throw new Error(`${value.reason} | ${value.message}`);
}
expect(value.value).toBe("testvalue");
}
});
test("failed rewrite-token verify (override algorithm)", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
const { payload, verifySignature } = splitToken(token);
{
// 検証アルゴリズムを「検証なし」に書き換える
const headerObject = { alg: "none" };
const payloadObject = JSON.parse(payload) as {
value: string;
iat: number;
exp: number;
};
// 内容を操作して構築しなおした場合、失敗する
const customToken = rebuildToken(
JSON.stringify(headerObject),
JSON.stringify(payloadObject),
verifySignature
);
const value = verify<{ value: string }>(customToken, publicKey);
if (!isVerifyError(value)) {
throw new Error(JSON.stringify(payload));
}
expect(value.reason).toBe("InvalidToken");
expect(value.message).toBe("invalid algorithm");
}
});
test("failed rewrite-token verify (override expire)", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
const { header, payload, verifySignature } = splitToken(token);
{
// expの値を操作する
const payloadObject = JSON.parse(payload) as {
value: string;
iat: number;
exp: number;
};
payloadObject.exp = payloadObject.exp + 100000;
// 内容を操作して構築しなおした場合、失敗する
const customToken = rebuildToken(
header,
JSON.stringify(payloadObject),
verifySignature
);
const value = verify<{ value: string }>(customToken, publicKey);
if (!isVerifyError(value)) {
throw new Error(JSON.stringify(payload));
}
expect(value.reason).toBe("InvalidToken");
expect(value.message).toBe("invalid signature");
}
});
// JWT改竄テスト用ユーティリティ
const splitToken = (
token: string
): { header: string; payload: string; verifySignature: string } => {
const splited = token.split(".");
const header = base64url.decode(splited[0]);
const payload = base64url.decode(splited[1]);
const verifySignature = splited[2];
return { header, payload, verifySignature };
};
// JWT改竄テスト用ユーティリティ
const rebuildToken = (
header: string,
payload: string,
verifySignature: string
): string => {
const rebuild_header = base64url.encode(header);
const rebuild_payload = base64url.encode(payload);
return `${rebuild_header}.${rebuild_payload}.${verifySignature}`;
};
// テスト用に生成した秘密鍵
const privateKey = [
"-----BEGIN RSA PRIVATE KEY-----",
"MIIEpAIBAAKCAQEAsTVLNpW0/FzVCU7qo1DDjOkYWx6s/jE56YOOc3UzaaG/zb1F",
"GyfRoUUgS4DnQxPNz9oM63RpQlhvG6UCwx23tL7p3PS0ZCsLeggcyLctbJAzLy/a",
"fF9ABoreorqp/AaEs+Vdwbykb+M+nB2Sxsc57Tli2x8NiOZr5dafs3vMuIIKNsBa",
"FAugFrd2ApxXR04jBRAorZRRFPtECE7D+hxDalw5DCd0mmdY0vrbRsgkbej0Zzzq",
"zukJVXTMjy1YScqi3I9gLx2hLVmpK76Gtxn21AIcn8P3rKZmDyPH+9KNfWC8+ubF",
"+VuY6nItlCgiSyTKErAp6M9pyRHKbPpdUM3aIQIDAQABAoIBAQCk7fkmwIdGKhCN",
"LUns3opiZ8AnbpGLs702vR6kDvze35BoqDPdZl4RPwkjvMGBCLmRLly/+ATPnwcq",
"L5Y2iz4jl1yKLaaHZBi2Zz6DARnh5QP+cwdiojQw4qb7xcfXrSltVZjBbBWPnWz0",
"WAH3yAz94V9Emc47EFpz/CF/J0YOokxY8GlR4cwfK6NE0goAjzmatwV3IVFeR/eE",
"x6JZAmd/0HMfOn3k/NumAMCJXKnZMQBAMQ3AduTO2lbZm+29yBqymtzTGFjrj0gm",
"+E/ibD8vVzh0toPvUfPIqetdRT8vkUJ5UHhAkz9Vzvqhr6BhYhc2ft0x/z7HpaiX",
"cDqnaRLBAoGBAODdPEktK1VOVXhOuikZBUHXU25iQdQRbM4kCtWiE8lBZ/f+6OPc",
"BN+OedYMDhpFe/oFqGU4t610SPO1CdVRPnWHhMSabjh9G3gqOZjSW5tEAgT2wi+H",
"IOVXnsos1qCMFdXWgVZw6F8wNcui9VabGic/EOqMRihEeSOjcradTSQFAoGBAMm+",
"y2wZ8usanIDzADgTJnA4kBZzhIxK6qcPf3tPVXKuFUOFWwzGiDXeXTwM0sWN7kGb",
"iymqhTWlYETQ3C6jPXTJiyOSco1rw45wO+xSHeQvUzXpk+9whbVAlhTcoVGiKz+9",
"BS7+3+lKtBzXDNADxQfSGjiGb+ceilBGLV+WurRtAoGAPxn2a/aP/X1hAMTe+t95",
"mTNqx0Qtguxs4yA8Jh04fjarjW1sP10jxPR/fjCd2IN9OflSey1CZhuGyVUZcFI/",
"O84O1PkdSx7YkY0P4rHNYTHhezEf5yR9d75x4fxZMm59RifO3coLe4LU5dNSE76s",
"xSyue5NnsK8ea4DXlSVpW10CgYAfHz3GWWJt/lbyVYpNHDcrzK39qKhj9BKq3ust",
"nJlz7YL+PY5ENERC+yCq6NeC/lgo6tPXA6U1F2P4ebfdwfTzFTxPqoHdayhpysqT",
"tD9EOkC96mCV6WfXBDWi1j5Ul43QcVphW5QzKwEKCerCFDLK+BBvc93Da6SuqYTK",
"YDhBKQKBgQDKtNe8CjHRvkWoyKErMMpv5D0ce/yWq+oAaoqW1QKwngPyaiDeDwqM",
"iOJzQxtvK4YqMYQdkgj5VLfWzeazd28RLODZua6phe776zuUv93LHTvYq/8RZfhk",
"JIQJ7GETBnHmoTemwmJiSdVDsjJdtsyR4XRjIDNR5bGe7NNbZJpCUw==",
"-----END RSA PRIVATE KEY-----",
].join("\n");
// テスト用に生成した公開鍵
const publicKey = [
"-----BEGIN PUBLIC KEY-----",
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsTVLNpW0/FzVCU7qo1DD",
"jOkYWx6s/jE56YOOc3UzaaG/zb1FGyfRoUUgS4DnQxPNz9oM63RpQlhvG6UCwx23",
"tL7p3PS0ZCsLeggcyLctbJAzLy/afF9ABoreorqp/AaEs+Vdwbykb+M+nB2Sxsc5",
"7Tli2x8NiOZr5dafs3vMuIIKNsBaFAugFrd2ApxXR04jBRAorZRRFPtECE7D+hxD",
"alw5DCd0mmdY0vrbRsgkbej0ZzzqzukJVXTMjy1YScqi3I9gLx2hLVmpK76Gtxn2",
"1AIcn8P3rKZmDyPH+9KNfWC8+ubF+VuY6nItlCgiSyTKErAp6M9pyRHKbPpdUM3a",
"IQIDAQAB",
"-----END PUBLIC KEY-----",
].join("\n");
// テスト用に作成した、違う秘密鍵から生成した公開鍵
const anotherPublicKey = [
"-----BEGIN PUBLIC KEY-----",
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt1WsgrjpjsEfRa7vqlR3",
"2mGxErXpvC+uRQnFtSXdP4tEYicPb1cNFUcu5xW6attTyzKHKMzwJrvmKEKVYGig",
"n43rM+UyW79DNOQWQQblCHAc3hMolLWC+Tkw7xL4JhzZLH0rm5DF52YNYSicV1S9",
"RpxYEeyHUa+ExV82lT47ySWAwg+yPwtDeDPMbOxHXqyw1wdqR2WVuxsQBaIRQgMk",
"EL/qObQjA4e5jOOwERRvVLxzjhnldUZcG0cYGDfjPTewRYfCeXzMx2YM4Uo0vx0x",
"2ZIY+im061GvfugX4/31xB5YEi+62qIwuSL5UpKjMv5yx1cvIqO76Ro3XNwsR+81",
"KQIDAQAB",
"-----END PUBLIC KEY-----",
].join("\n");
// 秘密鍵のように見えるが想定する形式と違う
const fakePrivateKey = [
"-----BAGIN RSA PRIVATE KEY-----",
"MIIEpAIBAAKCAQEAsTVLNpW0/FzVCU7qo1DDjOkYWx6s/jE56YOOc3UzaaG/zb1F",
"GyfRoUUgS4DnQxPNz9oM63RpQlhvG6UCwx23tL7p3PS0ZCsLeggcyLctbJAzLy/a",
"fF9ABoreorqp/AaEs+Vdwbykb+M+nB2Sxsc57Tli2x8NiOZr5dafs3vMuIIKNsBa",
"FAugFrd2ApxXR04jBRAorZRRFPtECE7D+hxDalw5DCd0mmdY0vrbRsgkbej0Zzzq",
"zukJVXTMjy1YScqi3I9gLx2hLVmpK76Gtxn21AIcn8P3rKZmDyPH+9KNfWC8+ubF",
"+VuY6nItlCgiSyTKErAp6M9pyRHKbPpdUM3aIQIDAQABAoIBAQCk7fkmwIdGKhCN",
"LUns3opiZ8AnbpGLs702vR6kDvze35BoqDPdZl4RPwkjvMGBCLmRLly/+ATPnwcq",
"L5Y2iz4jl1yKLaaHZBi2Zz6DARnh5QP+cwdiojQw4qb7xcfXrSltVZjBbBWPnWz0",
"WAH3yAz94V9Emc47EFpz/CF/J0YOokxY8GlR4cwfK6NE0goAjzmatwV3IVFeR/eE",
"x6JZAmd/0HMfOn3k/NumAMCJXKnZMQBAMQ3AduTO2lbZm+29yBqymtzTGFjrj0gm",
"+E/ibD8vVzh0toPvUfPIqetdRT8vkUJ5UHhAkz9Vzvqhr6BhYhc2ft0x/z7HpaiX",
"cDqnaRLBAoGBAODdPEktK1VOVXhOuikZBUHXU25iQdQRbM4kCtWiE8lBZ/f+6OPc",
"BN+OedYMDhpFe/oFqGU4t610SPO1CdVRPnWHhMSabjh9G3gqOZjSW5tEAgT2wi+H",
"IOVXnsos1qCMFdXWgVZw6F8wNcui9VabGic/EOqMRihEeSOjcradTSQFAoGBAMm+",
"y2wZ8usanIDzADgTJnA4kBZzhIxK6qcPf3tPVXKuFUOFWwzGiDXeXTwM0sWN7kGb",
"iymqhTWlYETQ3C6jPXTJiyOSco1rw45wO+xSHeQvUzXpk+9whbVAlhTcoVGiKz+9",
"BS7+3+lKtBzXDNADxQfSGjiGb+ceilBGLV+WurRtAoGAPxn2a/aP/X1hAMTe+t95",
"mTNqx0Qtguxs4yA8Jh04fjarjW1sP10jxPR/fjCd2IN9OflSey1CZhuGyVUZcFI/",
"O84O1PkdSx7YkY0P4rHNYTHhezEf5yR9d75x4fxZMm59RifO3coLe4LU5dNSE76s",
"xSyue5NnsK8ea4DXlSVpW10CgYAfHz3GWWJt/lbyVYpNHDcrzK39qKhj9BKq3ust",
"nJlz7YL+PY5ENERC+yCq6NeC/lgo6tPXA6U1F2P4ebfdwfTzFTxPqoHdayhpysqT",
"tD9EOkC96mCV6WfXBDWi1j5Ul43QcVphW5QzKwEKCerCFDLK+BBvc93Da6SuqYTK",
"YDhBKQKBgQDKtNe8CjHRvkWoyKErMMpv5D0ce/yWq+oAaoqW1QKwngPyaiDeDwqM",
"iOJzQxtvK4YqMYQdkgj5VLfWzeazd28RLODZua6phe776zuUv93LHTvYq/8RZfhk",
"JIQJ7GETBnHmoTemwmJiSdVDsjJdtsyR4XRjIDNR5bGe7NNbZJpCUw==",
"-----END RSA PRIVATE KEY-----",
].join("\n");
// 公開鍵のように見えるが想定する形式と違う
const fakePublicKey = [
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt1WsgrjpjsEfRa7vqlR3",
"2mGxErXpvC+uRQnFtSXdP4tEYicPb1cNFUcu5xW6attTyzKHKMzwJrvmKEKVYGig",
"n43rM+UyW79DNOQWQQblCHAc3hMolLWC+Tkw7xL4JhzZLH0rm5DF52YNYSicV1S9",
"RpxYEeyHUa+ExV82lT47ySWAwg+yPwtDeDPMbOxHXqyw1wdqR2WVuxsQBaIRQgMk",
"EL/qObQjA4e5jOOwERRvVLxzjhnldUZcG0cYGDfjPTewRYfCeXzMx2YM4Uo0vx0x",
"2ZIY+im061GvfugX4/31xB5YEi+62qIwuSL5UpKjMv5yx1cvIqO76Ro3XNwsR+81",
"KQIDAQAB",
].join("\n");

View File

@ -0,0 +1,130 @@
import * as jwt from "jsonwebtoken";
// XXX: decodeがうまく使えないことがあるので応急対応 バージョン9以降だとなる
import { decode as jwtDecode } from "jsonwebtoken";
export type VerifyError = {
reason: "ExpiredError" | "InvalidToken" | "InvalidTimeStamp" | "Unknown";
message: string;
};
export const isVerifyError = (arg: unknown): arg is VerifyError => {
const value = arg as VerifyError;
if (value.message === undefined) {
return false;
}
if (value.reason === undefined) {
return false;
}
switch (value.reason) {
case "ExpiredError":
case "InvalidTimeStamp":
case "InvalidToken":
case "Unknown":
return true;
default:
return false;
}
};
/**
* Payloadと秘密鍵を使用して署名されたJWTを生成します
* @param {T} payload payloadの型
* @param {number} expirationSeconds ()
* @param {string} privateKey 使
* @return {string}
* @throws {Error} Errorオブジェクト
*/
export const sign = <T extends object>(
payload: T,
expirationSeconds: number,
privateKey: string
): string => {
try {
const token = jwt.sign(payload, privateKey, {
expiresIn: expirationSeconds,
algorithm: "RS256",
});
return token;
} catch (e) {
throw e;
}
};
/**
* tokenと公開鍵を使用して検証済みJWTのpayloadを取得します
* @param {string} token JWT
* @param {string} publicKey 使
* @return {T | VerifyError} Payload
*/
export const verify = <T extends object>(
token: string,
publicKey: string
): T | VerifyError => {
try {
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
}) as T;
return payload;
} catch (e) {
if (e instanceof jwt.TokenExpiredError) {
return {
reason: "ExpiredError",
message: e.message,
};
} else if (e instanceof jwt.NotBeforeError) {
return {
reason: "InvalidTimeStamp",
message: e.message,
};
} else if (e instanceof jwt.JsonWebTokenError) {
return {
reason: "InvalidToken",
message: e.message,
};
} else {
return {
reason: "Unknown",
message: e.message,
};
}
}
};
/**
* tokenから未検証のJWTのpayloadを取得します
* @param {string} token JWT
* @return {T | VerifyError} Payload
*/
export const decode = <T extends object>(token: string): T | VerifyError => {
try {
const payload = jwtDecode(token, {
json: true,
}) as T;
return payload;
} catch (e) {
if (e instanceof jwt.TokenExpiredError) {
return {
reason: "ExpiredError",
message: e.message,
};
} else if (e instanceof jwt.NotBeforeError) {
return {
reason: "InvalidTimeStamp",
message: e.message,
};
} else if (e instanceof jwt.JsonWebTokenError) {
return {
reason: "InvalidToken",
message: e.message,
};
} else {
return {
reason: "Unknown",
message: e.message,
};
}
}
};
export const getJwtKey = (key: string): string => key.replace(/\\n/g, "\n");

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