mirror of
https://github.com/bitwarden/server
synced 2026-01-26 14:23:21 +00:00
Merge branch 'main' into PM-30247-Defect-Previously-archived-items-do-not-return-to-Archive-when-importing
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "16"
|
||||
}
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {}
|
||||
},
|
||||
"mounts": [
|
||||
{
|
||||
|
||||
@@ -3,11 +3,46 @@ export DEV_DIR=/workspace/dev
|
||||
export CONTAINER_CONFIG=/workspace/.devcontainer/community_dev
|
||||
git config --global --add safe.directory /workspace
|
||||
|
||||
if [[ -z "${CODESPACES}" ]]; then
|
||||
allow_interactive=1
|
||||
else
|
||||
echo "Doing non-interactive setup"
|
||||
allow_interactive=0
|
||||
fi
|
||||
|
||||
get_option() {
|
||||
# Helper function for reading the value of an environment variable
|
||||
# primarily but then falling back to an interactive question if allowed
|
||||
# and lastly falling back to a default value input when either other
|
||||
# option is available.
|
||||
name_of_var="$1"
|
||||
question_text="$2"
|
||||
default_value="$3"
|
||||
is_secret="$4"
|
||||
|
||||
if [[ -n "${!name_of_var}" ]]; then
|
||||
# If the env variable they gave us has a value, then use that value
|
||||
echo "${!name_of_var}"
|
||||
elif [[ "$allow_interactive" == 1 ]]; then
|
||||
# If we can be interactive, then use the text they gave us to request input
|
||||
if [[ "$is_secret" == 1 ]]; then
|
||||
read -r -s -p "$question_text" response
|
||||
echo "$response"
|
||||
else
|
||||
read -r -p "$question_text" response
|
||||
echo "$response"
|
||||
fi
|
||||
else
|
||||
# If no environment variable and not interactive, then just give back default value
|
||||
echo "$default_value"
|
||||
fi
|
||||
}
|
||||
|
||||
get_installation_id_and_key() {
|
||||
pushd ./dev >/dev/null || exit
|
||||
echo "Please enter your installation id and key from https://bitwarden.com/host:"
|
||||
read -r -p "Installation id: " INSTALLATION_ID
|
||||
read -r -p "Installation key: " INSTALLATION_KEY
|
||||
INSTALLATION_ID="$(get_option "INSTALLATION_ID" "Installation id: " "00000000-0000-0000-0000-000000000001")"
|
||||
INSTALLATION_KEY="$(get_option "INSTALLATION_KEY" "Installation key: " "" 1)"
|
||||
jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" |
|
||||
.globalSettings.installation.key = \"$INSTALLATION_KEY\"" \
|
||||
secrets.json.example >secrets.json # create/overwrite secrets.json
|
||||
@@ -30,11 +65,10 @@ configure_other_vars() {
|
||||
}
|
||||
|
||||
one_time_setup() {
|
||||
read -r -p \
|
||||
"Would you like to configure your secrets and certificates for the first time?
|
||||
do_secrets_json_setup="$(get_option "SETUP_SECRETS_JSON" "Would you like to configure your secrets and certificates for the first time?
|
||||
WARNING: This will overwrite any existing secrets.json and certificate files.
|
||||
Proceed? [y/N] " response
|
||||
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
Proceed? [y/N] " "n")"
|
||||
if [[ "$do_secrets_json_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Running one-time setup script..."
|
||||
sleep 1
|
||||
get_installation_id_and_key
|
||||
@@ -50,11 +84,4 @@ Proceed? [y/N] " response
|
||||
fi
|
||||
}
|
||||
|
||||
# main
|
||||
if [[ -z "${CODESPACES}" ]]; then
|
||||
one_time_setup
|
||||
else
|
||||
# Ignore interactive elements when running in codespaces since they are not supported there
|
||||
# TODO Write codespaces specific instructions and link here
|
||||
echo "Running in codespaces, follow instructions here: https://contributing.bitwarden.com/getting-started/server/guide/ to continue the setup"
|
||||
fi
|
||||
one_time_setup
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "16"
|
||||
}
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {}
|
||||
},
|
||||
"mounts": [
|
||||
{
|
||||
@@ -24,6 +25,7 @@
|
||||
"extensions": ["ms-dotnettools.csdevkit"]
|
||||
}
|
||||
},
|
||||
"onCreateCommand": "bash .devcontainer/internal_dev/onCreateCommand.sh",
|
||||
"postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh",
|
||||
"forwardPorts": [1080, 1433, 3306, 5432, 10000, 10001, 10002],
|
||||
"portsAttributes": {
|
||||
|
||||
12
.devcontainer/internal_dev/onCreateCommand.sh
Normal file
12
.devcontainer/internal_dev/onCreateCommand.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
export REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
file="$REPO_ROOT/dev/custom-root-ca.crt"
|
||||
|
||||
if [ -e "$file" ]; then
|
||||
echo "Adding custom root CA"
|
||||
sudo cp "$file" /usr/local/share/ca-certificates/
|
||||
sudo update-ca-certificates
|
||||
else
|
||||
echo "No custom root CA found, skipping..."
|
||||
fi
|
||||
@@ -71,10 +71,10 @@ dotnet_naming_symbols.any_async_methods.applicable_kinds = method
|
||||
dotnet_naming_symbols.any_async_methods.applicable_accessibilities = *
|
||||
dotnet_naming_symbols.any_async_methods.required_modifiers = async
|
||||
|
||||
dotnet_naming_style.end_in_async.required_prefix =
|
||||
dotnet_naming_style.end_in_async.required_prefix =
|
||||
dotnet_naming_style.end_in_async.required_suffix = Async
|
||||
dotnet_naming_style.end_in_async.capitalization = pascal_case
|
||||
dotnet_naming_style.end_in_async.word_separator =
|
||||
dotnet_naming_style.end_in_async.word_separator =
|
||||
|
||||
# Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items.
|
||||
dotnet_diagnostic.CS0618.severity = suggestion
|
||||
@@ -85,6 +85,12 @@ dotnet_diagnostic.CS0612.severity = suggestion
|
||||
# Remove unnecessary using directives https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005
|
||||
dotnet_diagnostic.IDE0005.severity = warning
|
||||
|
||||
# Specify CultureInfo https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1304
|
||||
dotnet_diagnostic.CA1304.severity = warning
|
||||
|
||||
# Specify IFormatProvider https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1305
|
||||
dotnet_diagnostic.CA1305.severity = warning
|
||||
|
||||
# CSharp code style settings:
|
||||
[*.cs]
|
||||
# Prefer "var" everywhere
|
||||
|
||||
14
.github/renovate.json5
vendored
14
.github/renovate.json5
vendored
@@ -21,12 +21,6 @@
|
||||
commitMessagePrefix: "[deps] AC:",
|
||||
reviewers: ["team:team-admin-console-dev"],
|
||||
},
|
||||
{
|
||||
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
|
||||
description: "Admin & SSO npm packages",
|
||||
commitMessagePrefix: "[deps] Auth:",
|
||||
reviewers: ["team:team-auth-dev"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"DuoUniversal",
|
||||
@@ -182,6 +176,14 @@
|
||||
matchUpdateTypes: ["minor"],
|
||||
addLabels: ["hold"],
|
||||
},
|
||||
{
|
||||
groupName: "Admin and SSO npm dependencies",
|
||||
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
|
||||
matchUpdateTypes: ["minor", "patch"],
|
||||
description: "Admin & SSO npm packages",
|
||||
commitMessagePrefix: "[deps] Auth:",
|
||||
reviewers: ["team:team-auth-dev"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
|
||||
groupName: "EntityFrameworkCore",
|
||||
|
||||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Verify format
|
||||
run: dotnet format --verify-no-changes
|
||||
@@ -119,10 +119,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
@@ -294,7 +294,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
@@ -420,7 +420,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
|
||||
6
.github/workflows/test-database.yml
vendored
6
.github/workflows/test-database.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Restore tools
|
||||
run: dotnet tool restore
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"'
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
|
||||
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Install rust
|
||||
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
|
||||
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
|
||||
84
.vscode/launch.json
vendored
84
.vscode/launch.json
vendored
@@ -69,6 +69,28 @@
|
||||
"preLaunchTask": "buildFullServer",
|
||||
"stopAll": true
|
||||
},
|
||||
{
|
||||
"name": "Full Server with Seeder API",
|
||||
"configurations": [
|
||||
"run-Admin",
|
||||
"run-API",
|
||||
"run-Events",
|
||||
"run-EventsProcessor",
|
||||
"run-Identity",
|
||||
"run-Sso",
|
||||
"run-Icons",
|
||||
"run-Billing",
|
||||
"run-Notifications",
|
||||
"run-SeederAPI"
|
||||
],
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "AA_compounds",
|
||||
"order": 6
|
||||
},
|
||||
"preLaunchTask": "buildFullServerWithSeederApi",
|
||||
"stopAll": true
|
||||
},
|
||||
{
|
||||
"name": "Self Host: Bit",
|
||||
"configurations": [
|
||||
@@ -204,6 +226,17 @@
|
||||
},
|
||||
"preLaunchTask": "buildSso",
|
||||
},
|
||||
{
|
||||
"name": "Seeder API",
|
||||
"configurations": [
|
||||
"run-SeederAPI"
|
||||
],
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "cloud",
|
||||
},
|
||||
"preLaunchTask": "buildSeederAPI",
|
||||
},
|
||||
{
|
||||
"name": "Admin Self Host",
|
||||
"configurations": [
|
||||
@@ -270,6 +303,17 @@
|
||||
},
|
||||
"preLaunchTask": "buildSso",
|
||||
},
|
||||
{
|
||||
"name": "Seeder API Self Host",
|
||||
"configurations": [
|
||||
"run-SeederAPI-SelfHost"
|
||||
],
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "self-host",
|
||||
},
|
||||
"preLaunchTask": "buildSeederAPI",
|
||||
}
|
||||
],
|
||||
"configurations": [
|
||||
// Configurations represent run-only scenarios so that they can be used in multiple compounds
|
||||
@@ -311,6 +355,25 @@
|
||||
"/Views": "${workspaceFolder}/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run-SeederAPI",
|
||||
"presentation": {
|
||||
"hidden": true,
|
||||
},
|
||||
"requireExactSource": true,
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/util/SeederApi",
|
||||
"stopAtEntry": false,
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run-Billing",
|
||||
"presentation": {
|
||||
@@ -488,6 +551,27 @@
|
||||
"/Views": "${workspaceFolder}/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run-SeederAPI-SelfHost",
|
||||
"presentation": {
|
||||
"hidden": true,
|
||||
},
|
||||
"requireExactSource": true,
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/util/SeederApi",
|
||||
"stopAtEntry": false,
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_URLS": "http://localhost:5048",
|
||||
"developSelfHosted": "true",
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run-Admin-SelfHost",
|
||||
"presentation": {
|
||||
|
||||
69
.vscode/tasks.json
vendored
69
.vscode/tasks.json
vendored
@@ -43,6 +43,21 @@
|
||||
"label": "buildFullServer",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildAdmin",
|
||||
"buildAPI",
|
||||
"buildEventsProcessor",
|
||||
"buildIdentity",
|
||||
"buildSso",
|
||||
"buildIcons",
|
||||
"buildBilling",
|
||||
"buildNotifications"
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "buildFullServerWithSeederApi",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildAdmin",
|
||||
"buildAPI",
|
||||
@@ -52,6 +67,7 @@
|
||||
"buildIcons",
|
||||
"buildBilling",
|
||||
"buildNotifications",
|
||||
"buildSeederAPI"
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -89,6 +105,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -102,6 +121,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -115,6 +137,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -128,6 +153,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -141,6 +169,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -154,6 +185,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -167,6 +201,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -180,6 +217,29 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "buildSeederAPI",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/util/SeederApi/SeederApi.csproj",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
@@ -197,6 +257,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
@@ -214,6 +277,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
@@ -224,6 +290,9 @@
|
||||
"label": "test",
|
||||
"type": "shell",
|
||||
"command": "dotnet test",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.12.2</Version>
|
||||
<Version>2026.1.0</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -13,21 +13,21 @@
|
||||
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<MicrosoftNetTestSdkVersion>18.0.1</MicrosoftNetTestSdkVersion>
|
||||
|
||||
|
||||
<XUnitVersion>2.6.6</XUnitVersion>
|
||||
|
||||
|
||||
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
|
||||
|
||||
|
||||
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
|
||||
|
||||
|
||||
<NSubstituteVersion>5.1.0</NSubstituteVersion>
|
||||
|
||||
|
||||
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
|
||||
|
||||
|
||||
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -137,10 +137,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\Rus
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.IntegrationTest", "test\Server.IntegrationTest\Server.IntegrationTest.csproj", "{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -353,6 +359,14 @@ Global
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -361,6 +375,10 @@ Global
|
||||
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -417,8 +435,11 @@ Global
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
|
||||
{FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
|
||||
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
||||
|
||||
@@ -44,6 +44,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"IncludeScopes": false,
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"System": "Information",
|
||||
"Microsoft": "Information"
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
},
|
||||
"Console": {
|
||||
"IncludeScopes": true,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using Bit.Sso.Utilities;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
|
||||
namespace Bit.Sso.IdentityServer;
|
||||
|
||||
/// <summary>
|
||||
/// Distributed cache-backed persisted grant store for short-lived grants.
|
||||
/// Uses IFusionCache (which wraps IDistributedCache) for horizontal scaling support,
|
||||
/// and fall back to in-memory caching if Redis is not configured.
|
||||
/// Designed for SSO authorization codes which are short-lived (5 minutes) and single-use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is purposefully a different implementation from how Identity solves Persisted Grants.
|
||||
/// Because even flavored grant store, e.g., AuthorizationCodeGrantStore, can add intermediary
|
||||
/// logic to a grant's handling by type, the fact that they all wrap IdentityServer's IPersistedGrantStore
|
||||
/// leans on IdentityServer's opinion that all grants, regardless of type, go to the same persistence
|
||||
/// mechanism (cache, database).
|
||||
/// <seealso href="https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/"/>
|
||||
/// </remarks>
|
||||
public class DistributedCachePersistedGrantStore : IPersistedGrantStore
|
||||
{
|
||||
private readonly IFusionCache _cache;
|
||||
|
||||
public DistributedCachePersistedGrantStore(
|
||||
[FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] IFusionCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<PersistedGrant?> GetAsync(string key)
|
||||
{
|
||||
var result = await _cache.TryGetAsync<PersistedGrant>(key);
|
||||
|
||||
if (!result.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var grant = result.Value;
|
||||
|
||||
// Check if grant has expired - remove expired grants from cache
|
||||
if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow)
|
||||
{
|
||||
await RemoveAsync(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return grant;
|
||||
}
|
||||
|
||||
public Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter)
|
||||
{
|
||||
// Cache stores are key-value based and don't support querying by filter criteria.
|
||||
// This method is typically used for cleanup operations on long-lived grants in databases.
|
||||
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
|
||||
|
||||
return Task.FromResult(Enumerable.Empty<PersistedGrant>());
|
||||
}
|
||||
|
||||
public Task RemoveAllAsync(PersistedGrantFilter filter)
|
||||
{
|
||||
// Revocation Strategy: SSO's logout flow (AccountController.LogoutAsync) only clears local
|
||||
// authentication cookies and performs federated logout with external IdPs. It does not invoke
|
||||
// Duende's EndSession or TokenRevocation endpoints. Authorization codes are single-use and expire
|
||||
// within 5 minutes, making explicit revocation unnecessary for SSO's security model.
|
||||
// https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/
|
||||
|
||||
// Cache stores are key-value based and don't support bulk deletion by filter.
|
||||
// This method is typically used for cleanup operations on long-lived grants in databases.
|
||||
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
|
||||
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(string key)
|
||||
{
|
||||
await _cache.RemoveAsync(key);
|
||||
}
|
||||
|
||||
public async Task StoreAsync(PersistedGrant grant)
|
||||
{
|
||||
// Calculate TTL based on grant expiration
|
||||
var duration = grant.Expiration.HasValue
|
||||
? grant.Expiration.Value - DateTime.UtcNow
|
||||
: TimeSpan.FromMinutes(5); // Default to 5 minutes if no expiration set
|
||||
|
||||
// Ensure positive duration
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache key "sso-grants:" is configured by service registration. Going through the consumed KeyedService will
|
||||
// give us a consistent cache key prefix for these grants.
|
||||
await _cache.SetAsync(
|
||||
grant.Key,
|
||||
grant,
|
||||
new FusionCacheEntryOptions { Duration = duration });
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public static class PersistedGrantsDistributedCacheConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// The SSO Persisted Grant cache key. Identifies the keyed service consumed by the SSO Persisted Grant Store as
|
||||
/// well as the cache key/namespace for grant storage.
|
||||
/// </summary>
|
||||
public const string CacheKey = "sso-grants";
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Bit.Sso.IdentityServer;
|
||||
using Bit.Sso.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.ResponseHandling;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Sustainsys.Saml2.AspNetCore2;
|
||||
|
||||
@@ -77,6 +78,17 @@ public static class ServiceCollectionExtensions
|
||||
})
|
||||
.AddIdentityServerCertificate(env, globalSettings);
|
||||
|
||||
// PM-23572
|
||||
// Register named FusionCache for SSO authorization code grants.
|
||||
// Provides separation of concerns and automatic Redis/in-memory negotiation
|
||||
// .AddInMemoryCaching should still persist above; this handles configuration caching, etc.,
|
||||
// and is separate from this keyed service, which only serves grant negotiation.
|
||||
services.AddExtendedCache(PersistedGrantsDistributedCacheConstants.CacheKey, globalSettings);
|
||||
|
||||
// Store authorization codes in distributed cache for horizontal scaling
|
||||
// Uses named FusionCache which gracefully degrades to in-memory when Redis isn't configured
|
||||
services.AddSingleton<IPersistedGrantStore, DistributedCachePersistedGrantStore>();
|
||||
|
||||
return identityServerBuilder;
|
||||
}
|
||||
}
|
||||
|
||||
94
bitwarden_license/src/Sso/package-lock.json
generated
94
bitwarden_license/src/Sso/package-lock.json
generated
@@ -17,9 +17,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
},
|
||||
@@ -749,9 +749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.18",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
|
||||
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
|
||||
"version": "2.9.13",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
|
||||
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -792,9 +792,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
|
||||
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -813,11 +813,11 @@
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
"electron-to-chromium": "^1.5.227",
|
||||
"node-releases": "^2.0.21",
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -834,9 +834,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||
"version": "1.0.30001763",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
|
||||
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -988,9 +988,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.237",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
|
||||
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -1022,9 +1022,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1418,13 +1418,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
|
||||
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.11.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -1541,9 +1545,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.26",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
|
||||
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1874,9 +1878,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.93.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
|
||||
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
|
||||
"version": "1.97.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
|
||||
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2109,9 +2113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.3.14",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
|
||||
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
|
||||
"version": "5.3.16",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
|
||||
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2165,9 +2169,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2217,9 +2221,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.102.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
|
||||
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
|
||||
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2232,21 +2236,21 @@
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.26.3",
|
||||
"browserslist": "^4.28.1",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.17.3",
|
||||
"es-module-lexer": "^1.2.1",
|
||||
"enhanced-resolve": "^5.17.4",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"eslint-scope": "5.1.1",
|
||||
"events": "^3.2.0",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"json-parse-even-better-errors": "^2.3.1",
|
||||
"loader-runner": "^4.2.0",
|
||||
"loader-runner": "^4.3.1",
|
||||
"mime-types": "^2.1.27",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^4.3.3",
|
||||
"tapable": "^2.3.0",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"terser-webpack-plugin": "^5.3.16",
|
||||
"watchpack": "^2.4.4",
|
||||
"webpack-sources": "^3.3.3"
|
||||
},
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
using Bit.Sso.IdentityServer;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using NSubstitute;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
|
||||
namespace Bit.SSO.Test.IdentityServer;
|
||||
|
||||
public class DistributedCachePersistedGrantStoreTests
|
||||
{
|
||||
private readonly IFusionCache _cache;
|
||||
private readonly DistributedCachePersistedGrantStore _sut;
|
||||
|
||||
public DistributedCachePersistedGrantStoreTests()
|
||||
{
|
||||
_cache = Substitute.For<IFusionCache>();
|
||||
_sut = new DistributedCachePersistedGrantStore(_cache);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_StoresGrantWithCalculatedTTL()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("test-key", expiration: DateTime.UtcNow.AddMinutes(5));
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).SetAsync(
|
||||
"test-key",
|
||||
grant,
|
||||
Arg.Is<FusionCacheEntryOptions>(opts =>
|
||||
opts.Duration >= TimeSpan.FromMinutes(4.9) &&
|
||||
opts.Duration <= TimeSpan.FromMinutes(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_WithNoExpiration_UsesDefaultFiveMinuteTTL()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("no-expiry-key", expiration: null);
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).SetAsync(
|
||||
"no-expiry-key",
|
||||
grant,
|
||||
Arg.Is<FusionCacheEntryOptions>(opts => opts.Duration == TimeSpan.FromMinutes(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_WithAlreadyExpiredGrant_DoesNotStore()
|
||||
{
|
||||
// Arrange
|
||||
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(expiredGrant);
|
||||
|
||||
// Assert
|
||||
await _cache.DidNotReceive().SetAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<PersistedGrant>(),
|
||||
Arg.Any<FusionCacheEntryOptions?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_EnablesDistributedCache()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("distributed-key", expiration: DateTime.UtcNow.AddMinutes(5));
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).SetAsync(
|
||||
"distributed-key",
|
||||
grant,
|
||||
Arg.Is<FusionCacheEntryOptions>(opts =>
|
||||
opts.SkipDistributedCache == false &&
|
||||
opts.SkipDistributedCacheReadWhenStale == false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithValidGrant_ReturnsGrant()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5));
|
||||
_cache.TryGetAsync<PersistedGrant>("valid-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("valid-key");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("valid-key", result.Key);
|
||||
Assert.Equal("authorization_code", result.Type);
|
||||
Assert.Equal("test-subject", result.SubjectId);
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithNonExistentKey_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
_cache.TryGetAsync<PersistedGrant>("nonexistent-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.None);
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("nonexistent-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
|
||||
_cache.TryGetAsync<PersistedGrant>("expired-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.FromValue(expiredGrant));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("expired-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await _cache.Received(1).RemoveAsync("expired-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithNoExpiration_ReturnsGrant()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("no-expiry-key", expiration: null);
|
||||
_cache.TryGetAsync<PersistedGrant>("no-expiry-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("no-expiry-key");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("no-expiry-key", result.Key);
|
||||
Assert.Null(result.Expiration);
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_RemovesGrantFromCache()
|
||||
{
|
||||
// Act
|
||||
await _sut.RemoveAsync("remove-key");
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).RemoveAsync("remove-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_ReturnsEmptyCollection()
|
||||
{
|
||||
// Arrange
|
||||
var filter = new PersistedGrantFilter
|
||||
{
|
||||
SubjectId = "test-subject",
|
||||
SessionId = "test-session",
|
||||
ClientId = "test-client",
|
||||
Type = "authorization_code"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAllAsync(filter);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAllAsync_CompletesWithoutError()
|
||||
{
|
||||
// Arrange
|
||||
var filter = new PersistedGrantFilter
|
||||
{
|
||||
SubjectId = "test-subject",
|
||||
ClientId = "test-client"
|
||||
};
|
||||
|
||||
// Act & Assert - should not throw
|
||||
await _sut.RemoveAllAsync(filter);
|
||||
|
||||
// Verify no cache operations were performed
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_PreservesAllGrantProperties()
|
||||
{
|
||||
// Arrange
|
||||
var grant = new PersistedGrant
|
||||
{
|
||||
Key = "full-grant-key",
|
||||
Type = "authorization_code",
|
||||
SubjectId = "user-123",
|
||||
SessionId = "session-456",
|
||||
ClientId = "client-789",
|
||||
Description = "Test grant",
|
||||
CreationTime = DateTime.UtcNow.AddMinutes(-1),
|
||||
Expiration = DateTime.UtcNow.AddMinutes(5),
|
||||
ConsumedTime = null,
|
||||
Data = "{\"test\":\"data\"}"
|
||||
};
|
||||
|
||||
PersistedGrant? capturedGrant = null;
|
||||
await _cache.SetAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Do<PersistedGrant>(g => capturedGrant = g),
|
||||
Arg.Any<FusionCacheEntryOptions?>());
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedGrant);
|
||||
Assert.Equal(grant.Key, capturedGrant.Key);
|
||||
Assert.Equal(grant.Type, capturedGrant.Type);
|
||||
Assert.Equal(grant.SubjectId, capturedGrant.SubjectId);
|
||||
Assert.Equal(grant.SessionId, capturedGrant.SessionId);
|
||||
Assert.Equal(grant.ClientId, capturedGrant.ClientId);
|
||||
Assert.Equal(grant.Description, capturedGrant.Description);
|
||||
Assert.Equal(grant.CreationTime, capturedGrant.CreationTime);
|
||||
Assert.Equal(grant.Expiration, capturedGrant.Expiration);
|
||||
Assert.Equal(grant.ConsumedTime, capturedGrant.ConsumedTime);
|
||||
Assert.Equal(grant.Data, capturedGrant.Data);
|
||||
}
|
||||
|
||||
private static PersistedGrant CreateTestGrant(string key, DateTime? expiration)
|
||||
{
|
||||
return new PersistedGrant
|
||||
{
|
||||
Key = key,
|
||||
Type = "authorization_code",
|
||||
SubjectId = "test-subject",
|
||||
ClientId = "test-client",
|
||||
CreationTime = DateTime.UtcNow,
|
||||
Expiration = expiration,
|
||||
Data = "{\"test\":\"data\"}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
|
||||
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
|
||||
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Update="Properties\launchSettings.json">
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
|
||||
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
|
||||
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Update="Properties\launchSettings.json">
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
1
dev/.gitignore
vendored
1
dev/.gitignore
vendored
@@ -18,3 +18,4 @@ signingkey.jwk
|
||||
|
||||
# Reverse Proxy Conifg
|
||||
reverse-proxy.conf
|
||||
*.crt
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
},
|
||||
"licenseDirectory": "<full path to license directory>",
|
||||
"enableNewDeviceVerification": true,
|
||||
"enableEmailVerification": true
|
||||
"enableEmailVerification": true,
|
||||
"communication": {
|
||||
"bootstrap": "none",
|
||||
"ssoCookieVendor": {
|
||||
"idpLoginUrl": "",
|
||||
"cookieName": "",
|
||||
"cookieDomain": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
dev/setup_secrets.ps1
Normal file → Executable file
23
dev/setup_secrets.ps1
Normal file → Executable file
@@ -2,7 +2,7 @@
|
||||
# Helper script for applying the same user secrets to each project
|
||||
param (
|
||||
[switch]$clear,
|
||||
[Parameter(ValueFromRemainingArguments = $true, Position=1)]
|
||||
[Parameter(ValueFromRemainingArguments = $true, Position = 1)]
|
||||
$cmdArgs
|
||||
)
|
||||
|
||||
@@ -16,17 +16,18 @@ if ($clear -eq $true) {
|
||||
}
|
||||
|
||||
$projects = @{
|
||||
Admin = "../src/Admin"
|
||||
Api = "../src/Api"
|
||||
Billing = "../src/Billing"
|
||||
Events = "../src/Events"
|
||||
EventsProcessor = "../src/EventsProcessor"
|
||||
Icons = "../src/Icons"
|
||||
Identity = "../src/Identity"
|
||||
Notifications = "../src/Notifications"
|
||||
Sso = "../bitwarden_license/src/Sso"
|
||||
Scim = "../bitwarden_license/src/Scim"
|
||||
Admin = "../src/Admin"
|
||||
Api = "../src/Api"
|
||||
Billing = "../src/Billing"
|
||||
Events = "../src/Events"
|
||||
EventsProcessor = "../src/EventsProcessor"
|
||||
Icons = "../src/Icons"
|
||||
Identity = "../src/Identity"
|
||||
Notifications = "../src/Notifications"
|
||||
Sso = "../bitwarden_license/src/Sso"
|
||||
Scim = "../bitwarden_license/src/Scim"
|
||||
IntegrationTests = "../test/Infrastructure.IntegrationTest"
|
||||
SeederApi = "../util/SeederApi"
|
||||
}
|
||||
|
||||
foreach ($key in $projects.keys) {
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
Validates that new database migration files follow naming conventions and chronological order.
|
||||
|
||||
.DESCRIPTION
|
||||
This script validates migration files in util/Migrator/DbScripts/ to ensure:
|
||||
This script validates migration files to ensure:
|
||||
|
||||
For SQL migrations in util/Migrator/DbScripts/:
|
||||
1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql
|
||||
2. New migrations are chronologically ordered (filename sorts after existing migrations)
|
||||
3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5)
|
||||
4. A 2-digit sequence number is included (e.g., _00, _01)
|
||||
|
||||
For Entity Framework migrations in util/MySqlMigrations, util/PostgresMigrations, util/SqliteMigrations:
|
||||
1. New migrations follow the naming format: YYYYMMDDHHMMSS_Description.cs
|
||||
2. Each migration has both .cs and .Designer.cs files
|
||||
3. New migrations are chronologically ordered (timestamp sorts after existing migrations)
|
||||
|
||||
.PARAMETER BaseRef
|
||||
The base git reference to compare against (e.g., 'main', 'HEAD~1')
|
||||
|
||||
@@ -58,75 +65,288 @@ $currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/"
|
||||
# Find added migrations
|
||||
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
|
||||
|
||||
$sqlValidationFailed = $false
|
||||
|
||||
if ($addedMigrations.Count -eq 0) {
|
||||
Write-Host "No new migration files added."
|
||||
exit 0
|
||||
Write-Host "No new SQL migration files added."
|
||||
Write-Host ""
|
||||
}
|
||||
else {
|
||||
Write-Host "New SQL migration files detected:"
|
||||
$addedMigrations | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host ""
|
||||
|
||||
# Get the last migration from base reference
|
||||
if ($baseMigrations.Count -eq 0) {
|
||||
Write-Host "No previous SQL migrations found (initial commit?). Skipping chronological validation."
|
||||
Write-Host ""
|
||||
}
|
||||
else {
|
||||
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
|
||||
Write-Host "Last SQL migration in base reference: $lastBaseMigration"
|
||||
Write-Host ""
|
||||
|
||||
# Required format regex: YYYY-MM-DD_NN_Description.sql
|
||||
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
|
||||
|
||||
foreach ($migration in $addedMigrations) {
|
||||
$migrationName = Split-Path -Leaf $migration
|
||||
|
||||
# Validate NEW migration filename format
|
||||
if ($migrationName -notmatch $formatRegex) {
|
||||
Write-Host "ERROR: Migration '$migrationName' does not match required format"
|
||||
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " - YYYY: 4-digit year"
|
||||
Write-Host " - MM: 2-digit month with leading zero (01-12)"
|
||||
Write-Host " - DD: 2-digit day with leading zero (01-31)"
|
||||
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
|
||||
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
|
||||
$sqlValidationFailed = $true
|
||||
continue
|
||||
}
|
||||
|
||||
# Compare migration name with last base migration (using ordinal string comparison)
|
||||
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
|
||||
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
|
||||
$sqlValidationFailed = $true
|
||||
}
|
||||
else {
|
||||
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($sqlValidationFailed) {
|
||||
Write-Host "FAILED: One or more SQL migrations are incorrectly named or not in chronological order"
|
||||
Write-Host ""
|
||||
Write-Host "All new SQL migration files must:"
|
||||
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
|
||||
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
|
||||
Write-Host " 4. Have a filename that sorts after the last migration in base"
|
||||
Write-Host ""
|
||||
Write-Host "To fix this issue:"
|
||||
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
|
||||
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " 3. Ensure the date is after $lastBaseMigration"
|
||||
Write-Host ""
|
||||
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
|
||||
}
|
||||
else {
|
||||
Write-Host "SUCCESS: All new SQL migrations are correctly named and in chronological order"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "New migration files detected:"
|
||||
$addedMigrations | ForEach-Object { Write-Host " $_" }
|
||||
# ===========================================================================================
|
||||
# Validate Entity Framework Migrations
|
||||
# ===========================================================================================
|
||||
|
||||
Write-Host "==================================================================="
|
||||
Write-Host "Validating Entity Framework Migrations"
|
||||
Write-Host "==================================================================="
|
||||
Write-Host ""
|
||||
|
||||
# Get the last migration from base reference
|
||||
if ($baseMigrations.Count -eq 0) {
|
||||
Write-Host "No previous migrations found (initial commit?). Skipping validation."
|
||||
exit 0
|
||||
}
|
||||
$efMigrationPaths = @(
|
||||
@{ Path = "util/MySqlMigrations/Migrations"; Name = "MySQL" },
|
||||
@{ Path = "util/PostgresMigrations/Migrations"; Name = "Postgres" },
|
||||
@{ Path = "util/SqliteMigrations/Migrations"; Name = "SQLite" }
|
||||
)
|
||||
|
||||
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
|
||||
Write-Host "Last migration in base reference: $lastBaseMigration"
|
||||
Write-Host ""
|
||||
$efValidationFailed = $false
|
||||
|
||||
# Required format regex: YYYY-MM-DD_NN_Description.sql
|
||||
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
|
||||
foreach ($migrationPathInfo in $efMigrationPaths) {
|
||||
$efPath = $migrationPathInfo.Path
|
||||
$dbName = $migrationPathInfo.Name
|
||||
|
||||
$validationFailed = $false
|
||||
Write-Host "-------------------------------------------------------------------"
|
||||
Write-Host "Checking $dbName EF migrations in $efPath"
|
||||
Write-Host "-------------------------------------------------------------------"
|
||||
Write-Host ""
|
||||
|
||||
foreach ($migration in $addedMigrations) {
|
||||
$migrationName = Split-Path -Leaf $migration
|
||||
# Get list of migrations from base reference
|
||||
try {
|
||||
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$efPath/" 2>$null | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
|
||||
$baseMigrations = @()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
|
||||
$baseMigrations = @()
|
||||
}
|
||||
|
||||
# Validate NEW migration filename format
|
||||
if ($migrationName -notmatch $formatRegex) {
|
||||
Write-Host "ERROR: Migration '$migrationName' does not match required format"
|
||||
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " - YYYY: 4-digit year"
|
||||
Write-Host " - MM: 2-digit month with leading zero (01-12)"
|
||||
Write-Host " - DD: 2-digit day with leading zero (01-31)"
|
||||
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
|
||||
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
|
||||
$validationFailed = $true
|
||||
# Get list of migrations from current reference
|
||||
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$efPath/" | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
|
||||
|
||||
# Find added migrations
|
||||
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
|
||||
|
||||
if ($addedMigrations.Count -eq 0) {
|
||||
Write-Host "No new $dbName EF migration files added."
|
||||
Write-Host ""
|
||||
continue
|
||||
}
|
||||
|
||||
# Compare migration name with last base migration (using ordinal string comparison)
|
||||
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
|
||||
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
|
||||
$validationFailed = $true
|
||||
Write-Host "New $dbName EF migration files detected:"
|
||||
$addedMigrations | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host ""
|
||||
|
||||
# Get the last migration from base reference
|
||||
if ($baseMigrations.Count -eq 0) {
|
||||
Write-Host "No previous $dbName migrations found. Skipping chronological validation."
|
||||
Write-Host ""
|
||||
}
|
||||
else {
|
||||
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
|
||||
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
|
||||
Write-Host "Last $dbName migration in base reference: $lastBaseMigration"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# Required format regex: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs
|
||||
$efFormatRegex = '^[0-9]{14}_.+\.cs$'
|
||||
|
||||
# Group migrations by base name (without .Designer.cs suffix)
|
||||
$migrationGroups = @{}
|
||||
$unmatchedFiles = @()
|
||||
|
||||
foreach ($migration in $addedMigrations) {
|
||||
$migrationName = Split-Path -Leaf $migration
|
||||
|
||||
# Extract base name (remove .Designer.cs or .cs)
|
||||
if ($migrationName -match '^([0-9]{14}_.+?)(?:\.Designer)?\.cs$') {
|
||||
$baseName = $matches[1]
|
||||
if (-not $migrationGroups.ContainsKey($baseName)) {
|
||||
$migrationGroups[$baseName] = @()
|
||||
}
|
||||
$migrationGroups[$baseName] += $migrationName
|
||||
}
|
||||
else {
|
||||
# Track files that don't match the expected pattern
|
||||
$unmatchedFiles += $migrationName
|
||||
}
|
||||
}
|
||||
|
||||
# Flag any files that don't match the expected pattern
|
||||
if ($unmatchedFiles.Count -gt 0) {
|
||||
Write-Host "ERROR: The following migration files do not match the required format:"
|
||||
foreach ($unmatchedFile in $unmatchedFiles) {
|
||||
Write-Host " - $unmatchedFile"
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs"
|
||||
Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
|
||||
Write-Host " - Description: Descriptive name using PascalCase"
|
||||
Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
|
||||
Write-Host ""
|
||||
$efValidationFailed = $true
|
||||
}
|
||||
|
||||
foreach ($baseName in $migrationGroups.Keys | Sort-Object) {
|
||||
$files = $migrationGroups[$baseName]
|
||||
|
||||
# Validate format
|
||||
$mainFile = "$baseName.cs"
|
||||
$designerFile = "$baseName.Designer.cs"
|
||||
|
||||
if ($mainFile -notmatch $efFormatRegex) {
|
||||
Write-Host "ERROR: Migration '$mainFile' does not match required format"
|
||||
Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs"
|
||||
Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
|
||||
Write-Host "Example: 20250115120000_AddNewFeature.cs"
|
||||
$efValidationFailed = $true
|
||||
continue
|
||||
}
|
||||
|
||||
# Check that both .cs and .Designer.cs files exist
|
||||
$hasCsFile = $files -contains $mainFile
|
||||
$hasDesignerFile = $files -contains $designerFile
|
||||
|
||||
if (-not $hasCsFile) {
|
||||
Write-Host "ERROR: Missing main migration file: $mainFile"
|
||||
$efValidationFailed = $true
|
||||
}
|
||||
|
||||
if (-not $hasDesignerFile) {
|
||||
Write-Host "ERROR: Missing designer file: $designerFile"
|
||||
Write-Host "Each EF migration must have both a .cs and .Designer.cs file"
|
||||
$efValidationFailed = $true
|
||||
}
|
||||
|
||||
if (-not $hasCsFile -or -not $hasDesignerFile) {
|
||||
continue
|
||||
}
|
||||
|
||||
# Compare migration timestamp with last base migration (using ordinal string comparison)
|
||||
if ($baseMigrations.Count -gt 0) {
|
||||
if ([string]::CompareOrdinal($mainFile, $lastBaseMigration) -lt 0) {
|
||||
Write-Host "ERROR: New migration '$mainFile' is not chronologically after '$lastBaseMigration'"
|
||||
$efValidationFailed = $true
|
||||
}
|
||||
else {
|
||||
Write-Host "OK: '$mainFile' is chronologically after '$lastBaseMigration'"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "OK: '$mainFile' (no previous migrations to compare)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($efValidationFailed) {
|
||||
Write-Host "FAILED: One or more EF migrations are incorrectly named or not in chronological order"
|
||||
Write-Host ""
|
||||
Write-Host "All new EF migration files must:"
|
||||
Write-Host " 1. Follow the naming format: YYYYMMDDHHMMSS_Description.cs"
|
||||
Write-Host " 2. Include both .cs and .Designer.cs files"
|
||||
Write-Host " 3. Have a timestamp that sorts after the last migration in base"
|
||||
Write-Host ""
|
||||
Write-Host "To fix this issue:"
|
||||
Write-Host " 1. Locate your migration file(s) in the respective Migrations directory"
|
||||
Write-Host " 2. Ensure both .cs and .Designer.cs files exist"
|
||||
Write-Host " 3. Rename to follow format: YYYYMMDDHHMMSS_Description.cs"
|
||||
Write-Host " 4. Ensure the timestamp is after the last migration"
|
||||
Write-Host ""
|
||||
Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
|
||||
}
|
||||
else {
|
||||
Write-Host "SUCCESS: All new EF migrations are correctly named and in chronological order"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==================================================================="
|
||||
Write-Host "Validation Summary"
|
||||
Write-Host "==================================================================="
|
||||
|
||||
if ($sqlValidationFailed -or $efValidationFailed) {
|
||||
if ($sqlValidationFailed) {
|
||||
Write-Host "❌ SQL migrations validation FAILED"
|
||||
}
|
||||
else {
|
||||
Write-Host "✓ SQL migrations validation PASSED"
|
||||
}
|
||||
|
||||
if ($efValidationFailed) {
|
||||
Write-Host "❌ EF migrations validation FAILED"
|
||||
}
|
||||
else {
|
||||
Write-Host "✓ EF migrations validation PASSED"
|
||||
}
|
||||
|
||||
if ($validationFailed) {
|
||||
Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order"
|
||||
Write-Host ""
|
||||
Write-Host "All new migration files must:"
|
||||
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
|
||||
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
|
||||
Write-Host " 4. Have a filename that sorts after the last migration in base"
|
||||
Write-Host ""
|
||||
Write-Host "To fix this issue:"
|
||||
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
|
||||
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " 3. Ensure the date is after $lastBaseMigration"
|
||||
Write-Host ""
|
||||
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
|
||||
Write-Host "OVERALL RESULT: FAILED"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "SUCCESS: All new migrations are correctly named and in chronological order"
|
||||
exit 0
|
||||
else {
|
||||
Write-Host "✓ SQL migrations validation PASSED"
|
||||
Write-Host "✓ EF migrations validation PASSED"
|
||||
Write-Host ""
|
||||
Write-Host "OVERALL RESULT: SUCCESS"
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Admin</UserSecretsId>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Admin' " />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using System.Data.Common;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Admin.HostedServices;
|
||||
|
||||
@@ -30,7 +30,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
||||
// TODO: Maybe flip a flag somewhere to indicate migration is complete??
|
||||
break;
|
||||
}
|
||||
catch (SqlException e)
|
||||
catch (DbException e)
|
||||
{
|
||||
if (i >= maxMigrationAttempts)
|
||||
{
|
||||
@@ -40,7 +40,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
||||
else
|
||||
{
|
||||
_logger.LogError(e,
|
||||
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
|
||||
"Database unavailable for migration. Trying again (attempt #{AttemptNumber})...", i + 1);
|
||||
await Task.Delay(20000, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ public class Startup
|
||||
default:
|
||||
break;
|
||||
}
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -20,11 +20,9 @@
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"IncludeScopes": false,
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"System": "Information",
|
||||
"Microsoft": "Information"
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
},
|
||||
"Console": {
|
||||
"IncludeScopes": true,
|
||||
|
||||
94
src/Admin/package-lock.json
generated
94
src/Admin/package-lock.json
generated
@@ -18,9 +18,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
},
|
||||
@@ -750,9 +750,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.18",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
|
||||
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
|
||||
"version": "2.9.13",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
|
||||
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -793,9 +793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
|
||||
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -814,11 +814,11 @@
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
"electron-to-chromium": "^1.5.227",
|
||||
"node-releases": "^2.0.21",
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -835,9 +835,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||
"version": "1.0.30001763",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
|
||||
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -989,9 +989,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.237",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
|
||||
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -1023,9 +1023,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1419,13 +1419,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
|
||||
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.11.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -1542,9 +1546,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.26",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
|
||||
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1875,9 +1879,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.93.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
|
||||
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
|
||||
"version": "1.97.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
|
||||
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2110,9 +2114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.3.14",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
|
||||
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
|
||||
"version": "5.3.16",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
|
||||
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2174,9 +2178,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2226,9 +2230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.102.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
|
||||
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
|
||||
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2241,21 +2245,21 @@
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.26.3",
|
||||
"browserslist": "^4.28.1",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.17.3",
|
||||
"es-module-lexer": "^1.2.1",
|
||||
"enhanced-resolve": "^5.17.4",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"eslint-scope": "5.1.1",
|
||||
"events": "^3.2.0",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"json-parse-even-better-errors": "^2.3.1",
|
||||
"loader-runner": "^4.2.0",
|
||||
"loader-runner": "^4.3.1",
|
||||
"mime-types": "^2.1.27",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^4.3.3",
|
||||
"tapable": "^2.3.0",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"terser-webpack-plugin": "^5.3.16",
|
||||
"watchpack": "^2.4.4",
|
||||
"webpack-sources": "^3.3.3"
|
||||
},
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,11 +665,6 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2))
|
||||
{
|
||||
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
|
||||
}
|
||||
|
||||
var currentUserId = _userService.GetProperUserId(User);
|
||||
if (currentUserId == null)
|
||||
{
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request;
|
||||
|
||||
public class OrganizationDomainRequestModel
|
||||
{
|
||||
[Required]
|
||||
[DomainNameValidator]
|
||||
public string DomainName { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
<ANCMPreConfiguredForIIS>true</ANCMPreConfiguredForIIS>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
|
||||
@@ -7,7 +7,7 @@ using Bit.Api.Auth.Models.Request;
|
||||
using Bit.Api.Auth.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
|
||||
using Bit.Core.Jobs;
|
||||
using Quartz;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
|
||||
using Bit.Core.Jobs;
|
||||
using Quartz;
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel
|
||||
existingEmergencyAccess.KeyEncrypted = KeyEncrypted;
|
||||
}
|
||||
existingEmergencyAccess.Type = Type;
|
||||
existingEmergencyAccess.WaitTimeDays = WaitTimeDays;
|
||||
existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays;
|
||||
return existingEmergencyAccess;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public class AccountsController(
|
||||
IFeatureService featureService,
|
||||
ILicensingService licensingService) : Controller
|
||||
{
|
||||
// TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work.
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
[HttpGet("subscription")]
|
||||
public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
|
||||
[FromServices] GlobalSettings globalSettings,
|
||||
@@ -61,7 +61,7 @@ public class AccountsController(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
[HttpPost("storage")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model)
|
||||
@@ -118,7 +118,7 @@ public class AccountsController(
|
||||
user.IsExpired());
|
||||
}
|
||||
|
||||
// TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
[HttpPost("reinstate-premium")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostReinstateAsync()
|
||||
@@ -131,10 +131,4 @@ public class AccountsController(
|
||||
|
||||
await userService.ReinstatePremiumAsync(user);
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
|
||||
{
|
||||
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
|
||||
return organizationsClaimingUser.Select(o => o.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ using Bit.Core.Billing.Licenses.Queries;
|
||||
using Bit.Core.Billing.Payment.Commands;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -21,9 +23,11 @@ namespace Bit.Api.Billing.Controllers.VNext;
|
||||
public class AccountBillingVNextController(
|
||||
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
|
||||
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
|
||||
IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery,
|
||||
IGetCreditQuery getCreditQuery,
|
||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||
IGetUserLicenseQuery getUserLicenseQuery,
|
||||
IReinstateSubscriptionCommand reinstateSubscriptionCommand,
|
||||
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
||||
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
|
||||
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
|
||||
@@ -91,10 +95,30 @@ public class AccountBillingVNextController(
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("storage")]
|
||||
[HttpGet("subscription")]
|
||||
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> UpdateStorageAsync(
|
||||
public async Task<IResult> GetSubscriptionAsync(
|
||||
[BindNever] User user)
|
||||
{
|
||||
var subscription = await getBitwardenSubscriptionQuery.Run(user);
|
||||
return TypedResults.Ok(subscription);
|
||||
}
|
||||
|
||||
[HttpPost("subscription/reinstate")]
|
||||
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> ReinstateSubscriptionAsync(
|
||||
[BindNever] User user)
|
||||
{
|
||||
var result = await reinstateSubscriptionCommand.Run(user);
|
||||
return Handle(result);
|
||||
}
|
||||
|
||||
[HttpPut("subscription/storage")]
|
||||
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> UpdateSubscriptionStorageAsync(
|
||||
[BindNever] User user,
|
||||
[FromBody] StorageUpdateRequest request)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,6 @@ public class StorageUpdateRequest : IValidatableObject
|
||||
/// Must be between 0 and the maximum allowed (minus base storage).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Range(0, 99)]
|
||||
public short AdditionalStorageGb { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
@@ -22,14 +21,14 @@ public class StorageUpdateRequest : IValidatableObject
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Additional storage cannot be negative.",
|
||||
new[] { nameof(AdditionalStorageGb) });
|
||||
[nameof(AdditionalStorageGb)]);
|
||||
}
|
||||
|
||||
if (AdditionalStorageGb > 99)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Maximum additional storage is 99 GB.",
|
||||
new[] { nameof(AdditionalStorageGb) });
|
||||
[nameof(AdditionalStorageGb)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.Models.Response;
|
||||
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
public class SubscriptionResponseModel : ResponseModel
|
||||
{
|
||||
|
||||
|
||||
@@ -67,8 +67,9 @@ public class CollectionsController : Controller
|
||||
{
|
||||
var collections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(_currentContext.OrganizationId.Value);
|
||||
|
||||
var collectionResponses = collections.Select(c =>
|
||||
new CollectionResponseModel(c.Item1, c.Item2.Groups));
|
||||
var collectionResponses = collections
|
||||
.Where(c => c.Item1.Type != CollectionType.DefaultUserCollection)
|
||||
.Select(c => new CollectionResponseModel(c.Item1, c.Item2.Groups));
|
||||
|
||||
var response = new ListResponseModel<CollectionResponseModel>(collectionResponses);
|
||||
return new JsonResult(response);
|
||||
|
||||
@@ -85,6 +85,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -5,9 +5,11 @@ using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Auth.UserFeatures.SendAccess;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
@@ -22,7 +24,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace Bit.Api.Tools.Controllers;
|
||||
|
||||
[Route("sends")]
|
||||
[Authorize("Application")]
|
||||
public class SendsController : Controller
|
||||
{
|
||||
private readonly ISendRepository _sendRepository;
|
||||
@@ -31,11 +32,10 @@ public class SendsController : Controller
|
||||
private readonly ISendFileStorageService _sendFileStorageService;
|
||||
private readonly IAnonymousSendCommand _anonymousSendCommand;
|
||||
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
|
||||
|
||||
private readonly ISendOwnerQuery _sendOwnerQuery;
|
||||
|
||||
private readonly ILogger<SendsController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
|
||||
public SendsController(
|
||||
ISendRepository sendRepository,
|
||||
@@ -46,7 +46,8 @@ public class SendsController : Controller
|
||||
ISendOwnerQuery sendOwnerQuery,
|
||||
ISendFileStorageService sendFileStorageService,
|
||||
ILogger<SendsController> logger,
|
||||
GlobalSettings globalSettings)
|
||||
IFeatureService featureService,
|
||||
IPushNotificationService pushNotificationService)
|
||||
{
|
||||
_sendRepository = sendRepository;
|
||||
_userService = userService;
|
||||
@@ -56,10 +57,12 @@ public class SendsController : Controller
|
||||
_sendOwnerQuery = sendOwnerQuery;
|
||||
_sendFileStorageService = sendFileStorageService;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
_featureService = featureService;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
}
|
||||
|
||||
#region Anonymous endpoints
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("access/{id}")]
|
||||
public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestModel model)
|
||||
@@ -73,21 +76,32 @@ public class SendsController : Controller
|
||||
|
||||
var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
|
||||
var send = await _sendRepository.GetByIdAsync(guid);
|
||||
|
||||
if (send == null)
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
|
||||
/* This guard can be removed once feature flag is retired*/
|
||||
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
|
||||
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
var sendAuthResult =
|
||||
await _sendAuthorizationService.AccessAsync(send, model.Password);
|
||||
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid password.");
|
||||
}
|
||||
|
||||
if (sendAuthResult.Equals(SendAccessResult.Denied))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
@@ -99,6 +113,7 @@ public class SendsController : Controller
|
||||
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
|
||||
sendResponse.CreatorIdentifier = creator.Email;
|
||||
}
|
||||
|
||||
return new ObjectResult(sendResponse);
|
||||
}
|
||||
|
||||
@@ -122,6 +137,13 @@ public class SendsController : Controller
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
|
||||
/* This guard can be removed once feature flag is retired*/
|
||||
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
|
||||
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId,
|
||||
model.Password);
|
||||
|
||||
@@ -129,21 +151,19 @@ public class SendsController : Controller
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
if (result.Equals(SendAccessResult.PasswordInvalid))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid password.");
|
||||
}
|
||||
|
||||
if (result.Equals(SendAccessResult.Denied))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new ObjectResult(new SendFileDownloadDataResponseModel()
|
||||
{
|
||||
Id = fileId,
|
||||
Url = url,
|
||||
});
|
||||
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url, });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
@@ -157,7 +177,8 @@ public class SendsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
|
||||
var blobName =
|
||||
eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
|
||||
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
|
||||
if (send == null)
|
||||
@@ -166,6 +187,7 @@ public class SendsController : Controller
|
||||
{
|
||||
await azureSendFileStorageService.DeleteBlobAsync(blobName);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,7 +195,8 @@ public class SendsController : Controller
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
|
||||
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}",
|
||||
JsonSerializer.Serialize(eventGridEvent));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -185,6 +208,7 @@ public class SendsController : Controller
|
||||
|
||||
#region Non-anonymous endpoints
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("{id}")]
|
||||
public async Task<SendResponseModel> Get(string id)
|
||||
{
|
||||
@@ -193,6 +217,7 @@ public class SendsController : Controller
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<SendResponseModel>> GetAll()
|
||||
{
|
||||
@@ -203,6 +228,67 @@ public class SendsController : Controller
|
||||
return result;
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.Send)]
|
||||
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
|
||||
[HttpPost("access/")]
|
||||
public async Task<IActionResult> AccessUsingAuth()
|
||||
{
|
||||
var guid = User.GetSendId();
|
||||
var send = await _sendRepository.GetByIdAsync(guid);
|
||||
if (send == null)
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
|
||||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
|
||||
send.DeletionDate < DateTime.UtcNow)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var sendResponse = new SendAccessResponseModel(send);
|
||||
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
|
||||
{
|
||||
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
|
||||
sendResponse.CreatorIdentifier = creator.Email;
|
||||
}
|
||||
|
||||
send.AccessCount++;
|
||||
await _sendRepository.ReplaceAsync(send);
|
||||
await _pushNotificationService.PushSyncSendUpdateAsync(send);
|
||||
|
||||
return new ObjectResult(sendResponse);
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.Send)]
|
||||
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
|
||||
[HttpPost("access/file/{fileId}")]
|
||||
public async Task<IActionResult> GetSendFileDownloadDataUsingAuth(string fileId)
|
||||
{
|
||||
var sendId = User.GetSendId();
|
||||
var send = await _sendRepository.GetByIdAsync(sendId);
|
||||
|
||||
if (send == null)
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
|
||||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
|
||||
send.DeletionDate < DateTime.UtcNow)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
|
||||
|
||||
send.AccessCount++;
|
||||
await _sendRepository.ReplaceAsync(send);
|
||||
await _pushNotificationService.PushSyncSendUpdateAsync(send);
|
||||
|
||||
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url });
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("")]
|
||||
public async Task<SendResponseModel> Post([FromBody] SendRequestModel model)
|
||||
{
|
||||
@@ -213,6 +299,7 @@ public class SendsController : Controller
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("file/v2")]
|
||||
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
|
||||
{
|
||||
@@ -243,6 +330,7 @@ public class SendsController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("{id}/file/{fileId}")]
|
||||
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
|
||||
{
|
||||
@@ -267,6 +355,7 @@ public class SendsController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("{id}/file/{fileId}")]
|
||||
[SelfHosted(SelfHostedOnly = true)]
|
||||
[RequestSizeLimit(Constants.FileSize501mb)]
|
||||
@@ -283,12 +372,14 @@ public class SendsController : Controller
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
|
||||
await Request.GetFileAsync(async (stream) =>
|
||||
{
|
||||
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut("{id}")]
|
||||
public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel model)
|
||||
{
|
||||
@@ -304,6 +395,7 @@ public class SendsController : Controller
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut("{id}/remove-password")]
|
||||
public async Task<SendResponseModel> PutRemovePassword(string id)
|
||||
{
|
||||
@@ -322,6 +414,28 @@ public class SendsController : Controller
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
// Removes ALL authentication (email or password) if any is present
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut("{id}/remove-auth")]
|
||||
public async Task<SendResponseModel> PutRemoveAuth(string id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// This endpoint exists because PUT preserves existing Password/Emails when not provided.
|
||||
// This allows clients to update other fields without re-submitting sensitive auth data.
|
||||
send.Password = null;
|
||||
send.Emails = null;
|
||||
send.AuthType = AuthType.None;
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(send);
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpDelete("{id}")]
|
||||
public async Task Delete(string id)
|
||||
{
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"IncludeScopes": false,
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"System": "Information",
|
||||
"Microsoft": "Information"
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
},
|
||||
"Console": {
|
||||
"IncludeScopes": true,
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Billing</UserSecretsId>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Label="Server SDK settings">
|
||||
<!-- These features will be gradually turned on -->
|
||||
<BitIncludeFeatures>false</BitIncludeFeatures>
|
||||
<BitIncludeTelemetry>false</BitIncludeTelemetry>
|
||||
<BitIncludeAuthentication>false</BitIncludeAuthentication>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Services;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class InvoiceCreatedHandler(
|
||||
IBraintreeService braintreeService,
|
||||
ILogger<InvoiceCreatedHandler> logger,
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
IProviderEventService providerEventService)
|
||||
: IInvoiceCreatedHandler
|
||||
{
|
||||
@@ -29,9 +30,9 @@ public class InvoiceCreatedHandler(
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]);
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer", "parent.subscription_details.subscription"]);
|
||||
|
||||
var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false;
|
||||
var usingPayPal = invoice.Customer.Metadata.ContainsKey("btCustomerId");
|
||||
|
||||
if (usingPayPal && invoice is
|
||||
{
|
||||
@@ -39,13 +40,12 @@ public class InvoiceCreatedHandler(
|
||||
Status: not StripeConstants.InvoiceStatus.Paid,
|
||||
CollectionMethod: "charge_automatically",
|
||||
BillingReason:
|
||||
"subscription_create" or
|
||||
"subscription_cycle" or
|
||||
"automatic_pending_invoice_item_invoice",
|
||||
Parent.SubscriptionDetails: not null
|
||||
Parent.SubscriptionDetails.Subscription: not null
|
||||
})
|
||||
{
|
||||
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
||||
await braintreeService.PayInvoice(invoice.Parent.SubscriptionDetails.Subscription, invoice);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
@@ -275,17 +275,24 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
.PreviousAttributes
|
||||
.ToObject<Subscription>() as Subscription;
|
||||
|
||||
// Get all plan IDs that include Secrets Manager support to check if the organization has secret manager in the
|
||||
// previous and/or current subscriptions.
|
||||
var planIdsOfPlansWithSecretManager = (await _pricingClient.ListPlans())
|
||||
.Where(orgPlan => orgPlan.SupportsSecretsManager && orgPlan.SecretsManager.StripeSeatPlanId != null)
|
||||
.Select(orgPlan => orgPlan.SecretsManager.StripeSeatPlanId)
|
||||
.ToHashSet();
|
||||
|
||||
// This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager.
|
||||
// If there are changes to any subscription item, Stripe sends every item in the subscription, both
|
||||
// changed and unchanged.
|
||||
var previousSubscriptionHasSecretsManager =
|
||||
previousSubscription?.Items is not null &&
|
||||
previousSubscription.Items.Any(
|
||||
previousSubscriptionItem => previousSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId);
|
||||
previousSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(previousSubscriptionItem.Plan.Id));
|
||||
|
||||
var currentSubscriptionHasSecretsManager =
|
||||
subscription.Items.Any(
|
||||
currentSubscriptionItem => currentSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId);
|
||||
currentSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(currentSubscriptionItem.Plan.Id));
|
||||
|
||||
if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager)
|
||||
{
|
||||
|
||||
@@ -627,7 +627,7 @@ public class UpcomingInvoiceHandler(
|
||||
{
|
||||
BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")),
|
||||
DiscountAmount = $"{coupon.PercentOff}%",
|
||||
DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||
DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US"))
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// PayPal IPN Client
|
||||
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();
|
||||
|
||||
@@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
UseApi = UseApi,
|
||||
UseResetPassword = UseResetPassword,
|
||||
UseSecretsManager = UseSecretsManager,
|
||||
UsePasswordManager = UsePasswordManager,
|
||||
SelfHost = SelfHost,
|
||||
UsersGetPremium = UsersGetPremium,
|
||||
UseCustomPermissions = UseCustomPermissions,
|
||||
@@ -156,6 +157,8 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
|
||||
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
|
||||
UsePhishingBlocker = UsePhishingBlocker,
|
||||
UseOrganizationDomains = UseOrganizationDomains,
|
||||
UseAutomaticUserConfirmation = UseAutomaticUserConfirmation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections;
|
||||
|
||||
public static class CollectionUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Arranges Collection and CollectionUser objects to create default user collections.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The organization ID.</param>
|
||||
/// <param name="organizationUserIds">The IDs for organization users who need default collections.</param>
|
||||
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
|
||||
/// <returns>A tuple containing the collections and collection users.</returns>
|
||||
public static (ICollection<Collection> collections, ICollection<CollectionUser> collectionUsers)
|
||||
BuildDefaultUserCollections(Guid organizationId, IEnumerable<Guid> organizationUserIds,
|
||||
string defaultCollectionName)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var collectionUsers = new List<CollectionUser>();
|
||||
var collections = new List<Collection>();
|
||||
|
||||
foreach (var orgUserId in organizationUserIds)
|
||||
{
|
||||
var collectionId = CoreHelpers.GenerateComb();
|
||||
|
||||
collections.Add(new Collection
|
||||
{
|
||||
Id = collectionId,
|
||||
OrganizationId = organizationId,
|
||||
Name = defaultCollectionName,
|
||||
CreationDate = now,
|
||||
RevisionDate = now,
|
||||
Type = CollectionType.DefaultUserCollection,
|
||||
DefaultUserCollectionEmail = null
|
||||
|
||||
});
|
||||
|
||||
collectionUsers.Add(new CollectionUser
|
||||
{
|
||||
CollectionId = collectionId,
|
||||
OrganizationUserId = orgUserId,
|
||||
ReadOnly = false,
|
||||
HidePasswords = false,
|
||||
Manage = true,
|
||||
});
|
||||
}
|
||||
|
||||
return (collections, collectionUsers);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@@ -83,19 +81,10 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
|
||||
return;
|
||||
}
|
||||
|
||||
await collectionRepository.CreateAsync(
|
||||
new Collection
|
||||
{
|
||||
OrganizationId = request.Organization!.Id,
|
||||
Name = request.DefaultUserCollectionName,
|
||||
Type = CollectionType.DefaultUserCollection
|
||||
},
|
||||
groups: null,
|
||||
[new CollectionAccessSelection
|
||||
{
|
||||
Id = request.OrganizationUser!.Id,
|
||||
Manage = true
|
||||
}]);
|
||||
await collectionRepository.CreateDefaultCollectionsAsync(
|
||||
request.Organization!.Id,
|
||||
[request.OrganizationUser!.Id],
|
||||
request.DefaultUserCollectionName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -14,7 +14,6 @@ using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@@ -294,21 +293,10 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultCollection = new Collection
|
||||
{
|
||||
OrganizationId = organizationUser.OrganizationId,
|
||||
Name = defaultUserCollectionName,
|
||||
Type = CollectionType.DefaultUserCollection
|
||||
};
|
||||
var collectionUser = new CollectionAccessSelection
|
||||
{
|
||||
Id = organizationUser.Id,
|
||||
ReadOnly = false,
|
||||
HidePasswords = false,
|
||||
Manage = true
|
||||
};
|
||||
|
||||
await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]);
|
||||
await _collectionRepository.CreateDefaultCollectionsAsync(
|
||||
organizationUser.OrganizationId,
|
||||
[organizationUser.Id],
|
||||
defaultUserCollectionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -339,7 +327,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
return;
|
||||
}
|
||||
|
||||
await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
|
||||
await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -93,7 +93,7 @@ public class RestoreOrganizationUserCommand(
|
||||
.twoFactorIsEnabled;
|
||||
}
|
||||
|
||||
if (organization.PlanType == PlanType.Free)
|
||||
if (organization.PlanType == PlanType.Free && organizationUser.UserId.HasValue)
|
||||
{
|
||||
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,4 @@ public interface IRevokeOrganizationUserCommand
|
||||
{
|
||||
Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId);
|
||||
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
|
||||
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
|
||||
}
|
||||
|
||||
@@ -68,68 +68,4 @@ public class RevokeOrganizationUserCommand(
|
||||
await organizationUserRepository.RevokeAsync(organizationUser.Id);
|
||||
organizationUser.Status = OrganizationUserStatusType.Revoked;
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId)
|
||||
{
|
||||
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
|
||||
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
|
||||
.ToList();
|
||||
|
||||
if (!filteredUsers.Any())
|
||||
{
|
||||
throw new BadRequestException("Users invalid.");
|
||||
}
|
||||
|
||||
if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds))
|
||||
{
|
||||
throw new BadRequestException("Organization must have at least one confirmed owner.");
|
||||
}
|
||||
|
||||
var deletingUserIsOwner = false;
|
||||
if (revokingUserId.HasValue)
|
||||
{
|
||||
deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId);
|
||||
}
|
||||
|
||||
var result = new List<Tuple<OrganizationUser, string>>();
|
||||
|
||||
foreach (var organizationUser in filteredUsers)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (organizationUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
throw new BadRequestException("Already revoked.");
|
||||
}
|
||||
|
||||
if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId)
|
||||
{
|
||||
throw new BadRequestException("You cannot revoke yourself.");
|
||||
}
|
||||
|
||||
if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue &&
|
||||
!deletingUserIsOwner)
|
||||
{
|
||||
throw new BadRequestException("Only owners can revoke other owners.");
|
||||
}
|
||||
|
||||
await organizationUserRepository.RevokeAsync(organizationUser.Id);
|
||||
organizationUser.Status = OrganizationUserStatusType.Revoked;
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
|
||||
result.Add(Tuple.Create(organizationUser, ""));
|
||||
}
|
||||
catch (BadRequestException e)
|
||||
{
|
||||
result.Add(Tuple.Create(organizationUser, e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +74,12 @@ public class AutomaticUserConfirmationPolicyEventHandler(
|
||||
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
|
||||
ICollection<OrganizationUserUserDetails> organizationUsers)
|
||||
{
|
||||
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
|
||||
organizationUsers.Select(ou => ou.UserId!.Value)))
|
||||
var userIds = organizationUsers.Where(
|
||||
u => u.UserId is not null &&
|
||||
u.Status != OrganizationUserStatusType.Invited)
|
||||
.Select(u => u.UserId!.Value);
|
||||
|
||||
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds))
|
||||
.Any(uo => uo.OrganizationId != organizationId
|
||||
&& uo.Status != OrganizationUserStatusType.Invited);
|
||||
|
||||
|
||||
@@ -57,14 +57,15 @@ public class OrganizationDataOwnershipPolicyValidator(
|
||||
var userOrgIds = requirements
|
||||
.Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId))
|
||||
.Where(request => request.ShouldCreateDefaultCollection)
|
||||
.Select(request => request.OrganizationUserId);
|
||||
.Select(request => request.OrganizationUserId)
|
||||
.ToList();
|
||||
|
||||
if (!userOrgIds.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(
|
||||
await collectionRepository.CreateDefaultCollectionsBulkAsync(
|
||||
policyUpdate.OrganizationId,
|
||||
userOrgIds,
|
||||
defaultCollectionName);
|
||||
|
||||
@@ -21,7 +21,9 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
|
||||
Task<IEnumerable<string>> GetOwnerEmailAddressesById(Guid organizationId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the organizations that have a verified domain matching the user's email domain.
|
||||
/// Gets the organizations that have claimed the user's account. Currently, only one organization may claim a user.
|
||||
/// This requires that the organization has claimed the user's domain and the user is an organization member.
|
||||
/// It excludes invited members.
|
||||
/// </summary>
|
||||
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ public class EmergencyAccess : ITableObject<Guid>
|
||||
public string KeyEncrypted { get; set; }
|
||||
public EmergencyAccessType Type { get; set; }
|
||||
public EmergencyAccessStatusType Status { get; set; }
|
||||
public int WaitTimeDays { get; set; }
|
||||
public short WaitTimeDays { get; set; }
|
||||
public DateTime? RecoveryInitiatedDate { get; set; }
|
||||
public DateTime? LastNotificationDate { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
@@ -19,7 +18,7 @@ using Bit.Core.Vault.Models.Data;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Bit.Core.Vault.Services;
|
||||
|
||||
namespace Bit.Core.Auth.Services;
|
||||
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess;
|
||||
|
||||
public class EmergencyAccessService : IEmergencyAccessService
|
||||
{
|
||||
@@ -61,7 +60,7 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
}
|
||||
|
||||
public async Task<EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime)
|
||||
public async Task<Entities.EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime)
|
||||
{
|
||||
if (!await _userService.CanAccessPremium(grantorUser))
|
||||
{
|
||||
@@ -73,13 +72,13 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector.");
|
||||
}
|
||||
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Entities.EmergencyAccess
|
||||
{
|
||||
GrantorId = grantorUser.Id,
|
||||
Email = emergencyContactEmail.ToLowerInvariant(),
|
||||
Status = EmergencyAccessStatusType.Invited,
|
||||
Type = accessType,
|
||||
WaitTimeDays = waitTime,
|
||||
WaitTimeDays = (short)waitTime,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
};
|
||||
@@ -113,7 +112,7 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser));
|
||||
}
|
||||
|
||||
public async Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService)
|
||||
public async Task<Entities.EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService)
|
||||
{
|
||||
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
|
||||
if (emergencyAccess == null)
|
||||
@@ -175,7 +174,7 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
await _emergencyAccessRepository.DeleteAsync(emergencyAccess);
|
||||
}
|
||||
|
||||
public async Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId)
|
||||
public async Task<Entities.EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId)
|
||||
{
|
||||
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
|
||||
if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted ||
|
||||
@@ -201,7 +200,7 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
return emergencyAccess;
|
||||
}
|
||||
|
||||
public async Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser)
|
||||
public async Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser)
|
||||
{
|
||||
if (!await _userService.CanAccessPremium(grantorUser))
|
||||
{
|
||||
@@ -311,7 +310,7 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
}
|
||||
|
||||
// TODO PM-21687: rename this to something like InitiateRecoveryTakeoverAsync
|
||||
public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser)
|
||||
public async Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser)
|
||||
{
|
||||
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
|
||||
|
||||
@@ -429,7 +428,7 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId);
|
||||
}
|
||||
|
||||
private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName)
|
||||
private async Task SendInviteAsync(Entities.EmergencyAccess emergencyAccess, string invitingUsersName)
|
||||
{
|
||||
var token = _dataProtectorTokenizer.Protect(new EmergencyAccessInviteTokenable(emergencyAccess, _globalSettings.OrganizationInviteExpirationHours));
|
||||
await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token);
|
||||
@@ -449,7 +448,7 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
*/
|
||||
//TODO PM-21687: this IsValidRequest() checks the validity based on the granteeUser. There should be a complementary method for the grantorUser
|
||||
private static bool IsValidRequest(
|
||||
EmergencyAccess availableAccess,
|
||||
Entities.EmergencyAccess availableAccess,
|
||||
User requestingUser,
|
||||
EmergencyAccessType requestedAccessType)
|
||||
{
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Entities;
|
||||
@@ -7,7 +6,7 @@ using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
|
||||
namespace Bit.Core.Auth.Services;
|
||||
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess;
|
||||
|
||||
public interface IEmergencyAccessService
|
||||
{
|
||||
@@ -20,7 +19,7 @@ public interface IEmergencyAccessService
|
||||
/// <param name="accessType">Type of emergency access allowed to the emergency contact</param>
|
||||
/// <param name="waitTime">The amount of time to pass before the invite is auto confirmed</param>
|
||||
/// <returns>a new Emergency Access object</returns>
|
||||
Task<EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime);
|
||||
Task<Entities.EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime);
|
||||
/// <summary>
|
||||
/// Sends an invite to the emergency contact associated with the emergency access id.
|
||||
/// </summary>
|
||||
@@ -37,7 +36,7 @@ public interface IEmergencyAccessService
|
||||
/// <param name="token">the tokenable that was sent via email</param>
|
||||
/// <param name="userService">service dependency</param>
|
||||
/// <returns>void</returns>
|
||||
Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
|
||||
Task<Entities.EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
|
||||
/// <summary>
|
||||
/// The creator of the emergency access request can delete the request.
|
||||
/// </summary>
|
||||
@@ -53,7 +52,7 @@ public interface IEmergencyAccessService
|
||||
/// <param name="key">The grantor user key encrypted by the grantee public key; grantee.PubicKey(grantor.User.Key)</param>
|
||||
/// <param name="grantorId">Id of grantor user</param>
|
||||
/// <returns>emergency access object associated with the Id passed in</returns>
|
||||
Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
|
||||
Task<Entities.EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
|
||||
/// <summary>
|
||||
/// Fetches an emergency access object. The grantor user must own the object being fetched.
|
||||
/// </summary>
|
||||
@@ -67,7 +66,7 @@ public interface IEmergencyAccessService
|
||||
/// <param name="emergencyAccess">emergency access entity being updated</param>
|
||||
/// <param name="grantorUser">grantor user</param>
|
||||
/// <returns>void</returns>
|
||||
Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser);
|
||||
Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser);
|
||||
/// <summary>
|
||||
/// Initiates the recovery process. For either Takeover or view. Will send an email to the Grantor User notifying of the initiation.
|
||||
/// </summary>
|
||||
@@ -107,7 +106,7 @@ public interface IEmergencyAccessService
|
||||
/// <param name="emergencyAccessId">Id of entity being accessed</param>
|
||||
/// <param name="granteeUser">grantee user of the emergency access entity</param>
|
||||
/// <returns>emergency access entity and the grantorUser</returns>
|
||||
Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser);
|
||||
Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser);
|
||||
/// <summary>
|
||||
/// Updates the grantor's password hash and updates the key for the EmergencyAccess entity.
|
||||
/// </summary>
|
||||
@@ -0,0 +1,14 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail;
|
||||
|
||||
public class EmergencyAccessRemoveGranteesMailView : BaseMailView
|
||||
{
|
||||
public required IEnumerable<string> RemovedGranteeNames { get; set; }
|
||||
public string EmergencyAccessHelpPageUrl => "https://bitwarden.com/help/emergency-access/";
|
||||
}
|
||||
|
||||
public class EmergencyAccessRemoveGranteesMail : BaseMail<EmergencyAccessRemoveGranteesMailView>
|
||||
{
|
||||
public override string Subject { get; set; } = "Emergency contacts removed";
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
<!doctype html>
|
||||
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<title></title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a { padding:0; }
|
||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
||||
p { display:block;margin:13px 0; }
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
.mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-hero-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
}
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
|
||||
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
|
||||
<!-- Blue Header Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div class="border-fix" style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 20px 0px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:150px;">
|
||||
|
||||
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
|
||||
|
||||
</h1></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:155px;">
|
||||
|
||||
<img alt src="undefined" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Main Content -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 20px 0px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 10px 0px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">The following emergency contacts have been removed from your account:
|
||||
<ul>
|
||||
{{#each RemovedGranteeNames}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
Learn more about <a href="{{EmergencyAccessHelpPageUrl}}">emergency access</a>.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://x.com/bitwarden" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://community.bitwarden.com/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://github.com/bitwarden" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.facebook.com/bitwarden/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
</p>
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
The following emergency contacts have been removed from your account:
|
||||
|
||||
{{#each RemovedGranteeNames}}
|
||||
{{this}}
|
||||
{{/each}}
|
||||
|
||||
Learn more about emergency access at {{EmergencyAccessHelpPageUrl}}
|
||||
@@ -42,6 +42,7 @@ public static class StripeConstants
|
||||
public static class ErrorCodes
|
||||
{
|
||||
public const string CustomerTaxLocationInvalid = "customer_tax_location_invalid";
|
||||
public const string InvoiceUpcomingNone = "invoice_upcoming_none";
|
||||
public const string PaymentMethodMicroDepositVerificationAttemptsExceeded = "payment_method_microdeposit_verification_attempts_exceeded";
|
||||
public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch";
|
||||
public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout";
|
||||
@@ -65,8 +66,10 @@ public static class StripeConstants
|
||||
public static class MetadataKeys
|
||||
{
|
||||
public const string BraintreeCustomerId = "btCustomerId";
|
||||
public const string BraintreeTransactionId = "btTransactionId";
|
||||
public const string InvoiceApproved = "invoice_approved";
|
||||
public const string OrganizationId = "organizationId";
|
||||
public const string PayPalTransactionId = "btPayPalTransactionId";
|
||||
public const string PreviousAdditionalStorage = "previous_additional_storage";
|
||||
public const string PreviousPeriodEndDate = "previous_period_end_date";
|
||||
public const string PreviousPremiumPriceId = "previous_premium_price_id";
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
namespace Bit.Core.Billing.Enums;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace Bit.Core.Billing.Enums;
|
||||
|
||||
public enum PlanCadenceType
|
||||
{
|
||||
[EnumMember(Value = "annually")]
|
||||
Annually,
|
||||
[EnumMember(Value = "monthly")]
|
||||
Monthly
|
||||
}
|
||||
|
||||
12
src/Core/Billing/Extensions/DiscountExtensions.cs
Normal file
12
src/Core/Billing/Extensions/DiscountExtensions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
public static class DiscountExtensions
|
||||
{
|
||||
public static bool AppliesTo(this Discount discount, SubscriptionItem subscriptionItem)
|
||||
=> discount.Coupon.AppliesTo.Products.Contains(subscriptionItem.Price.Product.Id);
|
||||
|
||||
public static bool IsValid(this Discount? discount)
|
||||
=> discount?.Coupon?.Valid ?? false;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
@@ -51,7 +52,7 @@ public static class InvoiceExtensions
|
||||
if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0)
|
||||
{
|
||||
var pricePerItem = (line.Amount / 100m) / line.Quantity;
|
||||
priceInfo = $"(at ${pricePerItem:F2} / month)";
|
||||
priceInfo = string.Format(CultureInfo.InvariantCulture, "(at ${0:F2} / month)", pricePerItem);
|
||||
}
|
||||
|
||||
var taxDescription = $"{line.Quantity} × Tax {priceInfo}";
|
||||
@@ -70,7 +71,7 @@ public static class InvoiceExtensions
|
||||
if (tax > 0)
|
||||
{
|
||||
var taxAmount = tax / 100m;
|
||||
items.Add($"1 × Tax (at ${taxAmount:F2} / month)");
|
||||
items.Add(string.Format(CultureInfo.InvariantCulture, "1 × Tax (at ${0:F2} / month)", taxAmount));
|
||||
}
|
||||
|
||||
return items;
|
||||
|
||||
@@ -12,8 +12,11 @@ using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Implementations;
|
||||
using Bit.Core.Billing.Subscriptions.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Queries;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Billing.Tax.Services.Implementations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Services.Implementations;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
@@ -39,6 +42,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
|
||||
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
|
||||
services.AddTransient<IPreviewOrganizationTaxCommand, PreviewOrganizationTaxCommand>();
|
||||
services.AddTransient<IGetBitwardenSubscriptionQuery, GetBitwardenSubscriptionQuery>();
|
||||
services.AddTransient<IReinstateSubscriptionCommand, ReinstateSubscriptionCommand>();
|
||||
services.AddTransient<IBraintreeService, BraintreeService>();
|
||||
}
|
||||
|
||||
private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Extensions;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Services;
|
||||
@@ -46,6 +48,57 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
|
||||
}
|
||||
|
||||
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
|
||||
|
||||
// If the license has a Token (claims-based), extract all properties from claims BEFORE validation
|
||||
// This ensures that CanUseLicense validation has access to the correct values from claims
|
||||
// Otherwise, fall back to using the properties already on the license object (backward compatibility)
|
||||
if (claimsPrincipal != null)
|
||||
{
|
||||
license.Name = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Name);
|
||||
license.BillingEmail = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BillingEmail);
|
||||
license.BusinessName = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BusinessName);
|
||||
license.PlanType = claimsPrincipal.GetValue<PlanType>(OrganizationLicenseConstants.PlanType);
|
||||
license.Seats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.Seats);
|
||||
license.MaxCollections = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxCollections);
|
||||
license.UsePolicies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePolicies);
|
||||
license.UseSso = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSso);
|
||||
license.UseKeyConnector = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseKeyConnector);
|
||||
license.UseScim = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseScim);
|
||||
license.UseGroups = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseGroups);
|
||||
license.UseDirectory = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDirectory);
|
||||
license.UseEvents = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseEvents);
|
||||
license.UseTotp = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseTotp);
|
||||
license.Use2fa = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Use2fa);
|
||||
license.UseApi = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseApi);
|
||||
license.UseResetPassword = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseResetPassword);
|
||||
license.Plan = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Plan);
|
||||
license.SelfHost = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.SelfHost);
|
||||
license.UsersGetPremium = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsersGetPremium);
|
||||
license.UseCustomPermissions = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseCustomPermissions);
|
||||
license.Enabled = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Enabled);
|
||||
license.Expires = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Expires);
|
||||
license.LicenseKey = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.LicenseKey);
|
||||
license.UsePasswordManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePasswordManager);
|
||||
license.UseSecretsManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSecretsManager);
|
||||
license.SmSeats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmSeats);
|
||||
license.SmServiceAccounts = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmServiceAccounts);
|
||||
license.UseRiskInsights = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseRiskInsights);
|
||||
license.UseOrganizationDomains = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseOrganizationDomains);
|
||||
license.UseAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies);
|
||||
license.UseAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation);
|
||||
license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDisableSmAdsForUsers);
|
||||
license.UsePhishingBlocker = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePhishingBlocker);
|
||||
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxStorageGb);
|
||||
license.InstallationId = claimsPrincipal.GetValue<Guid>(OrganizationLicenseConstants.InstallationId);
|
||||
license.LicenseType = claimsPrincipal.GetValue<LicenseType>(OrganizationLicenseConstants.LicenseType);
|
||||
license.Issued = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Issued);
|
||||
license.Refresh = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Refresh);
|
||||
license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
|
||||
license.Trial = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Trial);
|
||||
license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.LimitCollectionCreationDeletion);
|
||||
license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems);
|
||||
}
|
||||
|
||||
var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) &&
|
||||
selfHostedOrganization.CanUseLicense(license, out exception);
|
||||
|
||||
@@ -54,12 +107,6 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
|
||||
throw new BadRequestException(exception);
|
||||
}
|
||||
|
||||
var useAutomaticUserConfirmation = claimsPrincipal?
|
||||
.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false;
|
||||
|
||||
selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
|
||||
license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
|
||||
|
||||
await WriteLicenseFileAsync(selfHostedOrganization, license);
|
||||
await UpdateOrganizationAsync(selfHostedOrganization, license);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Platform.Push;
|
||||
@@ -49,6 +50,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
|
||||
|
||||
public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IBraintreeService braintreeService,
|
||||
IGlobalSettings globalSettings,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
@@ -300,6 +302,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
ValidateLocation = ValidateTaxLocationTiming.Immediately
|
||||
}
|
||||
};
|
||||
|
||||
return await stripeAdapter.UpdateCustomerAsync(customer.Id, options);
|
||||
}
|
||||
|
||||
@@ -351,14 +354,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
|
||||
var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
|
||||
|
||||
if (usingPayPal)
|
||||
if (!usingPayPal)
|
||||
{
|
||||
await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
|
||||
{
|
||||
AutoAdvance = false
|
||||
});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
|
||||
{
|
||||
AutoAdvance = false,
|
||||
Expand = ["customer"]
|
||||
});
|
||||
|
||||
await braintreeService.PayInvoice(new UserId(userId), invoice);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
@@ -10,6 +12,8 @@ using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Premium.Commands;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the storage allocation for a premium user's subscription.
|
||||
/// Handles both increases and decreases in storage in an idempotent manner.
|
||||
@@ -26,6 +30,7 @@ public interface IUpdatePremiumStorageCommand
|
||||
}
|
||||
|
||||
public class UpdatePremiumStorageCommand(
|
||||
IBraintreeService braintreeService,
|
||||
IStripeAdapter stripeAdapter,
|
||||
IUserService userService,
|
||||
IPricingClient pricingClient,
|
||||
@@ -34,19 +39,22 @@ public class UpdatePremiumStorageCommand(
|
||||
{
|
||||
public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb) => HandleAsync<None>(async () =>
|
||||
{
|
||||
if (!user.Premium)
|
||||
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
|
||||
{
|
||||
return new BadRequest("User does not have a premium subscription.");
|
||||
}
|
||||
|
||||
if (!user.MaxStorageGb.HasValue)
|
||||
{
|
||||
return new BadRequest("No access to storage.");
|
||||
return new BadRequest("User has no access to storage.");
|
||||
}
|
||||
|
||||
// Fetch all premium plans and the user's subscription to find which plan they're on
|
||||
var premiumPlans = await pricingClient.ListPremiumPlans();
|
||||
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId);
|
||||
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions
|
||||
{
|
||||
Expand = ["customer"]
|
||||
});
|
||||
|
||||
// Find the password manager subscription item (seat, not storage) and match it to a plan
|
||||
var passwordManagerItem = subscription.Items.Data.FirstOrDefault(i =>
|
||||
@@ -54,7 +62,7 @@ public class UpdatePremiumStorageCommand(
|
||||
|
||||
if (passwordManagerItem == null)
|
||||
{
|
||||
return new BadRequest("Premium subscription item not found.");
|
||||
return new Conflict("Premium subscription does not have a Password Manager line item.");
|
||||
}
|
||||
|
||||
var premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
|
||||
@@ -66,20 +74,20 @@ public class UpdatePremiumStorageCommand(
|
||||
return new BadRequest("Additional storage cannot be negative.");
|
||||
}
|
||||
|
||||
var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb);
|
||||
var maxStorageGb = (short)(baseStorageGb + additionalStorageGb);
|
||||
|
||||
if (newTotalStorageGb > 100)
|
||||
if (maxStorageGb > 100)
|
||||
{
|
||||
return new BadRequest("Maximum storage is 100 GB.");
|
||||
}
|
||||
|
||||
// Idempotency check: if user already has the requested storage, return success
|
||||
if (user.MaxStorageGb == newTotalStorageGb)
|
||||
if (user.MaxStorageGb == maxStorageGb)
|
||||
{
|
||||
return new None();
|
||||
}
|
||||
|
||||
var remainingStorage = user.StorageBytesRemaining(newTotalStorageGb);
|
||||
var remainingStorage = user.StorageBytesRemaining(maxStorageGb);
|
||||
if (remainingStorage < 0)
|
||||
{
|
||||
return new BadRequest(
|
||||
@@ -124,21 +132,46 @@ public class UpdatePremiumStorageCommand(
|
||||
});
|
||||
}
|
||||
|
||||
// Update subscription with prorations
|
||||
// Storage is billed annually, so we create prorations and invoice immediately
|
||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = subscriptionItemOptions,
|
||||
ProrationBehavior = Core.Constants.CreateProrations
|
||||
};
|
||||
var usingPayPal = subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
|
||||
|
||||
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions);
|
||||
if (usingPayPal)
|
||||
{
|
||||
var options = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = subscriptionItemOptions,
|
||||
ProrationBehavior = ProrationBehavior.CreateProrations
|
||||
};
|
||||
|
||||
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options);
|
||||
|
||||
var draftInvoice = await stripeAdapter.CreateInvoiceAsync(new InvoiceCreateOptions
|
||||
{
|
||||
Customer = subscription.CustomerId,
|
||||
Subscription = subscription.Id,
|
||||
AutoAdvance = false,
|
||||
CollectionMethod = CollectionMethod.ChargeAutomatically
|
||||
});
|
||||
|
||||
var finalizedInvoice = await stripeAdapter.FinalizeInvoiceAsync(draftInvoice.Id,
|
||||
new InvoiceFinalizeOptions { AutoAdvance = false, Expand = ["customer"] });
|
||||
|
||||
await braintreeService.PayInvoice(new UserId(user.Id), finalizedInvoice);
|
||||
}
|
||||
else
|
||||
{
|
||||
var options = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = subscriptionItemOptions,
|
||||
ProrationBehavior = ProrationBehavior.AlwaysInvoice
|
||||
};
|
||||
|
||||
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options);
|
||||
}
|
||||
|
||||
// Update the user's max storage
|
||||
user.MaxStorageGb = newTotalStorageGb;
|
||||
user.MaxStorageGb = maxStorageGb;
|
||||
await userService.SaveUserAsync(user);
|
||||
|
||||
// No payment intent needed - the subscription update will automatically create and finalize the invoice
|
||||
return new None();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ public interface IStripeAdapter
|
||||
Task<Subscription> CancelSubscriptionAsync(string id, SubscriptionCancelOptions options = null);
|
||||
Task<Invoice> GetInvoiceAsync(string id, InvoiceGetOptions options);
|
||||
Task<List<Invoice>> ListInvoicesAsync(StripeInvoiceListOptions options);
|
||||
Task<Invoice> CreateInvoiceAsync(InvoiceCreateOptions options);
|
||||
Task<Invoice> CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options);
|
||||
Task<List<Invoice>> SearchInvoiceAsync(InvoiceSearchOptions options);
|
||||
Task<Invoice> UpdateInvoiceAsync(string id, InvoiceUpdateOptions options);
|
||||
|
||||
@@ -116,6 +116,9 @@ public class StripeAdapter : IStripeAdapter
|
||||
return invoices;
|
||||
}
|
||||
|
||||
public Task<Invoice> CreateInvoiceAsync(InvoiceCreateOptions options) =>
|
||||
_invoiceService.CreateAsync(options);
|
||||
|
||||
public Task<Invoice> CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options) =>
|
||||
_invoiceService.CreatePreviewAsync(options);
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf.Types;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Commands;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public interface IReinstateSubscriptionCommand
|
||||
{
|
||||
Task<BillingCommandResult<None>> Run(ISubscriber subscriber);
|
||||
}
|
||||
|
||||
public class ReinstateSubscriptionCommand(
|
||||
ILogger<ReinstateSubscriptionCommand> logger,
|
||||
IStripeAdapter stripeAdapter) : BaseBillingCommand<ReinstateSubscriptionCommand>(logger), IReinstateSubscriptionCommand
|
||||
{
|
||||
public Task<BillingCommandResult<None>> Run(ISubscriber subscriber) => HandleAsync<None>(async () =>
|
||||
{
|
||||
var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId);
|
||||
|
||||
if (subscription is not
|
||||
{
|
||||
Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active,
|
||||
CancelAt: not null
|
||||
})
|
||||
{
|
||||
return new BadRequest("Subscription is not pending cancellation.");
|
||||
}
|
||||
|
||||
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions
|
||||
{
|
||||
CancelAtPeriodEnd = false
|
||||
});
|
||||
|
||||
return new None();
|
||||
});
|
||||
}
|
||||
61
src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs
Normal file
61
src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The type of discounts Bitwarden supports.
|
||||
/// </summary>
|
||||
public enum BitwardenDiscountType
|
||||
{
|
||||
[EnumMember(Value = "amount-off")]
|
||||
AmountOff,
|
||||
|
||||
[EnumMember(Value = "percent-off")]
|
||||
PercentOff
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A record representing a discount applied to a Bitwarden subscription.
|
||||
/// </summary>
|
||||
public record BitwardenDiscount
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of the discount.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<BitwardenDiscountType>))]
|
||||
public required BitwardenDiscountType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The value of the discount.
|
||||
/// </summary>
|
||||
public required decimal Value { get; init; }
|
||||
|
||||
public static implicit operator BitwardenDiscount(Discount? discount)
|
||||
{
|
||||
if (discount is not
|
||||
{
|
||||
Coupon.Valid: true
|
||||
})
|
||||
{
|
||||
return null!;
|
||||
}
|
||||
|
||||
return discount.Coupon switch
|
||||
{
|
||||
{ AmountOff: > 0 } => new BitwardenDiscount
|
||||
{
|
||||
Type = BitwardenDiscountType.AmountOff,
|
||||
Value = discount.Coupon.AmountOff.Value
|
||||
},
|
||||
{ PercentOff: > 0 } => new BitwardenDiscount
|
||||
{
|
||||
Type = BitwardenDiscountType.PercentOff,
|
||||
Value = discount.Coupon.PercentOff.Value
|
||||
},
|
||||
_ => null!
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
public record BitwardenSubscription
|
||||
{
|
||||
/// <summary>
|
||||
/// The status of the subscription.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subscription's cart, including line items, any discounts, and estimated tax.
|
||||
/// </summary>
|
||||
public required Cart Cart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage available and used for the subscription.
|
||||
/// <remarks>Allowed Subscribers: User, Organization</remarks>
|
||||
/// </summary>
|
||||
public Storage? Storage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If the subscription is pending cancellation, the date at which the
|
||||
/// subscription will be canceled.
|
||||
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? CancelAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The date the subscription was canceled.
|
||||
/// <remarks>Allowed Statuses: 'canceled'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? Canceled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The date of the next charge for the subscription.
|
||||
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? NextCharge { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The date the subscription will be or was suspended due to lack of payment.
|
||||
/// <remarks>Allowed Statuses: 'incomplete', 'incomplete_expired', 'past_due', 'unpaid'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? Suspension { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of days after the subscription goes 'past_due' the subscriber has to resolve their
|
||||
/// open invoices before the subscription is suspended.
|
||||
/// <remarks>Allowed Statuses: 'past_due'</remarks>
|
||||
/// </summary>
|
||||
public int? GracePeriod { get; init; }
|
||||
}
|
||||
83
src/Core/Billing/Subscriptions/Models/Cart.cs
Normal file
83
src/Core/Billing/Subscriptions/Models/Cart.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
public record CartItem
|
||||
{
|
||||
/// <summary>
|
||||
/// The client-side translation key for the name of the cart item.
|
||||
/// </summary>
|
||||
public required string TranslationKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The quantity of the cart item.
|
||||
/// </summary>
|
||||
public required long Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The unit-cost of the cart item.
|
||||
/// </summary>
|
||||
public required decimal Cost { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional discount applied specifically to this cart item.
|
||||
/// </summary>
|
||||
public BitwardenDiscount? Discount { get; init; }
|
||||
}
|
||||
|
||||
public record PasswordManagerCartItems
|
||||
{
|
||||
/// <summary>
|
||||
/// The Password Manager seats in the cart.
|
||||
/// </summary>
|
||||
public required CartItem Seats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The additional storage in the cart.
|
||||
/// </summary>
|
||||
public CartItem? AdditionalStorage { get; init; }
|
||||
}
|
||||
|
||||
public record SecretsManagerCartItems
|
||||
{
|
||||
/// <summary>
|
||||
/// The Secrets Manager seats in the cart.
|
||||
/// </summary>
|
||||
public required CartItem Seats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The additional service accounts in the cart.
|
||||
/// </summary>
|
||||
public CartItem? AdditionalServiceAccounts { get; init; }
|
||||
}
|
||||
|
||||
public record Cart
|
||||
{
|
||||
/// <summary>
|
||||
/// The Password Manager items in the cart.
|
||||
/// </summary>
|
||||
public required PasswordManagerCartItems PasswordManager { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The Secrets Manager items in the cart.
|
||||
/// </summary>
|
||||
public SecretsManagerCartItems? SecretsManager { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The cart's billing cadence.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<PlanCadenceType>))]
|
||||
public PlanCadenceType Cadence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional discount applied to the entire cart.
|
||||
/// </summary>
|
||||
public BitwardenDiscount? Discount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The estimated tax for the cart.
|
||||
/// </summary>
|
||||
public required decimal EstimatedTax { get; init; }
|
||||
}
|
||||
52
src/Core/Billing/Subscriptions/Models/Storage.cs
Normal file
52
src/Core/Billing/Subscriptions/Models/Storage.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
using OneOf;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
public record Storage
|
||||
{
|
||||
private const double _bytesPerGibibyte = 1073741824D;
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage the subscriber has available.
|
||||
/// </summary>
|
||||
public required short Available { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage the subscriber has used.
|
||||
/// </summary>
|
||||
public required double Used { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage the subscriber has used, formatted as a human-readable string.
|
||||
/// </summary>
|
||||
public required string ReadableUsed { get; init; }
|
||||
|
||||
public static implicit operator Storage(User user) => From(user);
|
||||
public static implicit operator Storage(Organization organization) => From(organization);
|
||||
|
||||
private static Storage From(OneOf<User, Organization> subscriber)
|
||||
{
|
||||
var maxStorageGB = subscriber.Match(
|
||||
user => user.MaxStorageGb,
|
||||
organization => organization.MaxStorageGb);
|
||||
|
||||
if (maxStorageGB == null)
|
||||
{
|
||||
return null!;
|
||||
}
|
||||
|
||||
var storage = subscriber.Match(
|
||||
user => user.Storage,
|
||||
organization => organization.Storage);
|
||||
|
||||
return new Storage
|
||||
{
|
||||
Available = maxStorageGB.Value,
|
||||
Used = Math.Round((storage ?? 0) / _bytesPerGibibyte, 2),
|
||||
ReadableUsed = CoreHelpers.ReadableBytesSize(storage ?? 0)
|
||||
};
|
||||
}
|
||||
}
|
||||
43
src/Core/Billing/Subscriptions/Models/SubscriberId.cs
Normal file
43
src/Core/Billing/Subscriptions/Models/SubscriberId.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Exceptions;
|
||||
using OneOf;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public record UserId(Guid Value);
|
||||
|
||||
public record OrganizationId(Guid Value);
|
||||
|
||||
public record ProviderId(Guid Value);
|
||||
|
||||
public class SubscriberId : OneOfBase<UserId, OrganizationId, ProviderId>
|
||||
{
|
||||
private SubscriberId(OneOf<UserId, OrganizationId, ProviderId> input) : base(input) { }
|
||||
|
||||
public static implicit operator SubscriberId(UserId value) => new(value);
|
||||
public static implicit operator SubscriberId(OrganizationId value) => new(value);
|
||||
public static implicit operator SubscriberId(ProviderId value) => new(value);
|
||||
|
||||
public static implicit operator SubscriberId(Subscription subscription)
|
||||
{
|
||||
if (subscription.Metadata.TryGetValue(MetadataKeys.UserId, out var userIdValue)
|
||||
&& Guid.TryParse(userIdValue, out var userId))
|
||||
{
|
||||
return new UserId(userId);
|
||||
}
|
||||
|
||||
if (subscription.Metadata.TryGetValue(MetadataKeys.OrganizationId, out var organizationIdValue)
|
||||
&& Guid.TryParse(organizationIdValue, out var organizationId))
|
||||
{
|
||||
return new OrganizationId(organizationId);
|
||||
}
|
||||
|
||||
return subscription.Metadata.TryGetValue(MetadataKeys.ProviderId, out var providerIdValue) &&
|
||||
Guid.TryParse(providerIdValue, out var providerId)
|
||||
? new ProviderId(providerId)
|
||||
: throw new ConflictException("Subscription does not have a valid subscriber ID");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Queries;
|
||||
|
||||
using static StripeConstants;
|
||||
using static Utilities;
|
||||
|
||||
public interface IGetBitwardenSubscriptionQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves detailed subscription information for a user, including subscription status,
|
||||
/// cart items, discounts, and billing details.
|
||||
/// </summary>
|
||||
/// <param name="user">The user whose subscription information to retrieve.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="BitwardenSubscription"/> containing the subscription details, or null if no
|
||||
/// subscription is found or the subscription status is not recognized.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Currently only supports <see cref="User"/> subscribers. Future versions will support all
|
||||
/// <see cref="ISubscriber"/> types (User and Organization).
|
||||
/// </remarks>
|
||||
Task<BitwardenSubscription> Run(User user);
|
||||
}
|
||||
|
||||
public class GetBitwardenSubscriptionQuery(
|
||||
ILogger<GetBitwardenSubscriptionQuery> logger,
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery
|
||||
{
|
||||
public async Task<BitwardenSubscription> Run(User user)
|
||||
{
|
||||
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions
|
||||
{
|
||||
Expand =
|
||||
[
|
||||
"customer.discount.coupon.applies_to",
|
||||
"discounts.coupon.applies_to",
|
||||
"items.data.price.product",
|
||||
"test_clock"
|
||||
]
|
||||
});
|
||||
|
||||
var cart = await GetPremiumCartAsync(subscription);
|
||||
|
||||
var baseSubscription = new BitwardenSubscription { Status = subscription.Status, Cart = cart, Storage = user };
|
||||
|
||||
switch (subscription.Status)
|
||||
{
|
||||
case SubscriptionStatus.Incomplete:
|
||||
case SubscriptionStatus.IncompleteExpired:
|
||||
return baseSubscription with { Suspension = subscription.Created.AddHours(23), GracePeriod = 1 };
|
||||
|
||||
case SubscriptionStatus.Trialing:
|
||||
case SubscriptionStatus.Active:
|
||||
return baseSubscription with
|
||||
{
|
||||
NextCharge = subscription.GetCurrentPeriodEnd(),
|
||||
CancelAt = subscription.CancelAt
|
||||
};
|
||||
|
||||
case SubscriptionStatus.PastDue:
|
||||
case SubscriptionStatus.Unpaid:
|
||||
var suspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription);
|
||||
if (suspension == null)
|
||||
{
|
||||
return baseSubscription;
|
||||
}
|
||||
return baseSubscription with { Suspension = suspension.SuspensionDate, GracePeriod = suspension.GracePeriod };
|
||||
|
||||
case SubscriptionStatus.Canceled:
|
||||
return baseSubscription with { Canceled = subscription.CanceledAt };
|
||||
|
||||
default:
|
||||
{
|
||||
logger.LogError("Subscription ({SubscriptionID}) has an unmanaged status ({Status})", subscription.Id, subscription.Status);
|
||||
throw new ConflictException("Subscription is in an invalid state. Please contact support for assistance.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Cart> GetPremiumCartAsync(
|
||||
Subscription subscription)
|
||||
{
|
||||
var plans = await pricingClient.ListPremiumPlans();
|
||||
|
||||
var passwordManagerSeatsItem = subscription.Items.FirstOrDefault(item =>
|
||||
plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id));
|
||||
|
||||
if (passwordManagerSeatsItem == null)
|
||||
{
|
||||
throw new ConflictException("Premium subscription does not have a Password Manager line item.");
|
||||
}
|
||||
|
||||
var additionalStorageItem = subscription.Items.FirstOrDefault(item =>
|
||||
plans.Any(plan => plan.Storage.StripePriceId == item.Price.Id));
|
||||
|
||||
var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription);
|
||||
|
||||
var passwordManagerSeats = new CartItem
|
||||
{
|
||||
TranslationKey = "premiumMembership",
|
||||
Quantity = passwordManagerSeatsItem.Quantity,
|
||||
Cost = GetCost(passwordManagerSeatsItem),
|
||||
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem))
|
||||
};
|
||||
|
||||
var additionalStorage = additionalStorageItem != null
|
||||
? new CartItem
|
||||
{
|
||||
TranslationKey = "additionalStorageGB",
|
||||
Quantity = additionalStorageItem.Quantity,
|
||||
Cost = GetCost(additionalStorageItem),
|
||||
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(additionalStorageItem))
|
||||
}
|
||||
: null;
|
||||
|
||||
var estimatedTax = await EstimateTaxAsync(subscription);
|
||||
|
||||
return new Cart
|
||||
{
|
||||
PasswordManager = new PasswordManagerCartItems
|
||||
{
|
||||
Seats = passwordManagerSeats,
|
||||
AdditionalStorage = additionalStorage
|
||||
},
|
||||
Cadence = PlanCadenceType.Annually,
|
||||
Discount = cartLevelDiscount,
|
||||
EstimatedTax = estimatedTax
|
||||
};
|
||||
}
|
||||
|
||||
#region Utilities
|
||||
|
||||
private async Task<decimal> EstimateTaxAsync(Subscription subscription)
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions
|
||||
{
|
||||
Customer = subscription.Customer.Id,
|
||||
Subscription = subscription.Id
|
||||
});
|
||||
|
||||
return GetCost(invoice.TotalTaxes);
|
||||
}
|
||||
catch (StripeException stripeException) when
|
||||
(stripeException.StripeError.Code == ErrorCodes.InvoiceUpcomingNone)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static decimal GetCost(OneOf<SubscriptionItem, List<InvoiceTotalTax>> value) =>
|
||||
value.Match(
|
||||
item => (item.Price.UnitAmountDecimal ?? 0) / 100M,
|
||||
taxes => taxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount) / 100M);
|
||||
|
||||
private static (Discount? CartLevel, List<Discount> ProductLevel) GetStripeDiscounts(
|
||||
Subscription subscription)
|
||||
{
|
||||
var discounts = new List<Discount>();
|
||||
|
||||
if (subscription.Customer.Discount.IsValid())
|
||||
{
|
||||
discounts.Add(subscription.Customer.Discount);
|
||||
}
|
||||
|
||||
discounts.AddRange(subscription.Discounts.Where(discount => discount.IsValid()));
|
||||
|
||||
var cartLevel = new List<Discount>();
|
||||
var productLevel = new List<Discount>();
|
||||
|
||||
foreach (var discount in discounts)
|
||||
{
|
||||
switch (discount)
|
||||
{
|
||||
case { Coupon.AppliesTo.Products: null or { Count: 0 } }:
|
||||
cartLevel.Add(discount);
|
||||
break;
|
||||
case { Coupon.AppliesTo.Products.Count: > 0 }:
|
||||
productLevel.Add(discount);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (cartLevel.FirstOrDefault(), productLevel);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -142,8 +142,8 @@ public static class FeatureFlagKeys
|
||||
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
|
||||
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
|
||||
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
|
||||
public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2";
|
||||
public const string PremiumAccessQuery = "pm-21411-premium-access-query";
|
||||
public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface";
|
||||
public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance";
|
||||
|
||||
/* Architecture */
|
||||
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
|
||||
@@ -163,8 +163,8 @@ public static class FeatureFlagKeys
|
||||
public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email";
|
||||
public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template";
|
||||
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
|
||||
public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required";
|
||||
public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
|
||||
public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
|
||||
|
||||
/* Autofill Team */
|
||||
public const string SSHAgent = "ssh-agent";
|
||||
@@ -174,6 +174,7 @@ public static class FeatureFlagKeys
|
||||
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
|
||||
public const string WindowsDesktopAutotype = "windows-desktop-autotype";
|
||||
public const string WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga";
|
||||
public const string NotificationUndeterminedCipherScenarioLogic = "undetermined-cipher-scenario-logic";
|
||||
|
||||
/* Billing Team */
|
||||
public const string TrialPayment = "PM-8163-trial-payment";
|
||||
@@ -202,6 +203,7 @@ public static class FeatureFlagKeys
|
||||
public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit";
|
||||
public const string DataRecoveryTool = "pm-28813-data-recovery-tool";
|
||||
public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration";
|
||||
public const string SdkKeyRotation = "pm-30144-sdk-key-rotation";
|
||||
public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration";
|
||||
|
||||
/* Mobile Team */
|
||||
@@ -229,23 +231,12 @@ public static class FeatureFlagKeys
|
||||
/// Enable this flag to share the send view used by the web and browser clients
|
||||
/// on the desktop client.
|
||||
/// </summary>
|
||||
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
|
||||
public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators";
|
||||
public const string UseChromiumImporter = "pm-23982-chromium-importer";
|
||||
public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe";
|
||||
public const string SendUIRefresh = "pm-28175-send-ui-refresh";
|
||||
public const string SendEmailOTP = "pm-19051-send-email-verification";
|
||||
|
||||
/// <summary>
|
||||
/// Enable this flag to output email/OTP authenticated sends from the `GET sends` endpoint. When
|
||||
/// this flag is disabled, the `GET sends` endpoint omits email/OTP authenticated sends.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This flag is server-side only, and only inhibits the endpoint returning all sends.
|
||||
/// Email/OTP sends can still be created and downloaded through other endpoints.
|
||||
/// </remarks>
|
||||
public const string PM19051_ListEmailOtpSends = "tools-send-email-otp-listing";
|
||||
|
||||
/* Vault Team */
|
||||
public const string CipherKeyEncryption = "cipher-key-encryption";
|
||||
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
|
||||
@@ -265,6 +256,7 @@ public static class FeatureFlagKeys
|
||||
/* DIRT Team */
|
||||
public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike";
|
||||
public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging";
|
||||
public const string EventManagementForHuntress = "event-management-for-huntress";
|
||||
|
||||
/* UIF Team */
|
||||
public const string RouterFocusManagement = "router-focus-management";
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<GenerateUserSecretsAttribute>false</GenerateUserSecretsAttribute>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
@@ -44,7 +46,7 @@
|
||||
<PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.23.0" />
|
||||
<PackageReference Include="Microsoft.Bot.Connector" Version="4.23.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.7.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.8.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
|
||||
|
||||
60
src/Core/Entities/PlayItem.cs
Normal file
60
src/Core/Entities/PlayItem.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// PlayItem is a join table tracking entities created during automated testing.
|
||||
/// A `PlayId` is supplied by the clients in the `x-play-id` header to inform the server
|
||||
/// that any data created should be associated with the play, and therefore cleaned up with it.
|
||||
/// </summary>
|
||||
public class PlayItem : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
[MaxLength(256)]
|
||||
public required string PlayId { get; init; }
|
||||
public Guid? UserId { get; init; }
|
||||
public Guid? OrganizationId { get; init; }
|
||||
public DateTime CreationDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generates and sets a new COMB GUID for the Id property.
|
||||
/// </summary>
|
||||
public void SetNewId()
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PlayItem record associated with a User.
|
||||
/// </summary>
|
||||
/// <param name="user">The user entity created during the play.</param>
|
||||
/// <param name="playId">The play identifier from the x-play-id header.</param>
|
||||
/// <returns>A new PlayItem instance tracking the user.</returns>
|
||||
public static PlayItem Create(User user, string playId)
|
||||
{
|
||||
return new PlayItem
|
||||
{
|
||||
PlayId = playId,
|
||||
UserId = user.Id,
|
||||
CreationDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PlayItem record associated with an Organization.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization entity created during the play.</param>
|
||||
/// <param name="playId">The play identifier from the x-play-id header.</param>
|
||||
/// <returns>A new PlayItem instance tracking the organization.</returns>
|
||||
public static PlayItem Create(Organization organization, string playId)
|
||||
{
|
||||
return new PlayItem
|
||||
{
|
||||
PlayId = playId,
|
||||
OrganizationId = organization.Id,
|
||||
CreationDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/spot-enterprise.png"
|
||||
title="You can now share passwords with members of {{OrganizationName}}!"
|
||||
button-text="Log in"
|
||||
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
|
||||
button-text="<b>Log in</b>"
|
||||
button-url="{{WebVaultUrl}}"
|
||||
/>
|
||||
</mj-wrapper>
|
||||
@@ -33,7 +33,7 @@
|
||||
icon-alt="Group Users Icon"
|
||||
text="You can easily access and share passwords with your team."
|
||||
foot-url-text="Share passwords in Bitwarden"
|
||||
foot-url="https://bitwarden.com/help/share-to-a-collection/"
|
||||
foot-url="https://bitwarden.com/help/sharing"
|
||||
/>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/spot-family-homes.png"
|
||||
title="You can now share passwords with members of {{OrganizationName}}!"
|
||||
button-text="Log in"
|
||||
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
|
||||
button-text="<b>Log in</b>"
|
||||
button-url="{{WebVaultUrl}}"
|
||||
/>
|
||||
</mj-wrapper>
|
||||
@@ -33,7 +33,7 @@
|
||||
icon-alt="Group Users Icon"
|
||||
text="You can easily share passwords with friends, family, or coworkers."
|
||||
foot-url-text="Share passwords in Bitwarden"
|
||||
foot-url="https://bitwarden.com/help/share-to-a-collection/"
|
||||
foot-url="https://bitwarden.com/help/sharing"
|
||||
/>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../../../../components/head.mjml" />
|
||||
</mj-head>
|
||||
<mj-body css-class="border-fix">
|
||||
<!-- Blue Header Section -->
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 0px 20px">
|
||||
<mj-bw-hero title=""/>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Main Content -->
|
||||
<mj-wrapper padding="0px 20px 0px 20px">
|
||||
<mj-section background-color="#fff" padding="0px 10px 0px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
The following emergency contacts have been removed from your account:
|
||||
<ul>
|
||||
{{#each RemovedGranteeNames}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
Learn more about <a href="{{EmergencyAccessHelpPageUrl}}">emergency access</a>.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../../components/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -18,7 +18,7 @@
|
||||
at {{BaseAnnualRenewalPrice}} + tax.
|
||||
</mj-text>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px 15px 15px">
|
||||
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
|
||||
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal.
|
||||
This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
|
||||
</mj-text>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually.
|
||||
</mj-text>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px 15px 15px">
|
||||
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
|
||||
This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.
|
||||
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal.
|
||||
This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
|
||||
</mj-text>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
Questions? Contact
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px 15px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal.
|
||||
This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.</div>
|
||||
|
||||
</td>
|
||||
@@ -271,12 +271,12 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
|
||||
<div style="background:#F3F6F9;background-color:#F3F6F9;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#F3F6F9;background-color:#F3F6F9;width:100%;border-radius:0px 0px 4px 4px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
|
||||
@@ -364,8 +364,8 @@
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
@@ -381,13 +381,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://x.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -404,13 +404,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -427,13 +427,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://community.bitwarden.com/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -450,13 +450,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://github.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -473,13 +473,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -496,13 +496,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -519,13 +519,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.facebook.com/bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -546,15 +546,15 @@
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
</p>
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually
|
||||
at {{BaseAnnualRenewalPrice}} + tax.
|
||||
|
||||
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
|
||||
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal.
|
||||
This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
|
||||
|
||||
Questions? Contact support@bitwarden.com
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Bit.Core.Models.Mail.Billing.Renewal.Premium;
|
||||
public class PremiumRenewalMailView : BaseMailView
|
||||
{
|
||||
public required string BaseMonthlyRenewalPrice { get; set; }
|
||||
public required string DiscountedMonthlyRenewalPrice { get; set; }
|
||||
public required string DiscountedAnnualRenewalPrice { get; set; }
|
||||
public required string DiscountAmount { get; set; }
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user