mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcc3273889 | ||
|
|
e9a4f33a7e | ||
|
|
ae820ba4ea | ||
|
|
94f1239050 | ||
|
|
acc1392a53 | ||
|
|
a6a9789091 | ||
|
|
fdb7410976 | ||
|
|
b146b8ee33 | ||
|
|
4b1afc953f | ||
|
|
4ecbe9603c | ||
|
|
847d449882 | ||
|
|
bef20cba85 | ||
|
|
ebf91ccc76 | ||
|
|
abfaff16c2 | ||
|
|
89fa895a0a | ||
|
|
334fef52e1 | ||
|
|
aa42bf13ce | ||
|
|
c486c2d3d0 | ||
|
|
0ec1cee99e | ||
|
|
ccbe4857a1 | ||
|
|
7f8b0afedb | ||
|
|
e94b7d7ad8 | ||
|
|
27baa38fd1 | ||
|
|
4af10dd3e3 | ||
|
|
269d33cab2 | ||
|
|
170e12595b | ||
|
|
b82bbaa2dd | ||
|
|
b8f3dbddbf | ||
|
|
663572fd10 | ||
|
|
5033cf7203 | ||
|
|
0fcab2f8f0 | ||
|
|
c3d790f664 | ||
|
|
0906a85d6f | ||
|
|
4da8dec4ac | ||
|
|
d6e3254a16 | ||
|
|
6dc698ff5e | ||
|
|
39e48458da | ||
|
|
c5453f83e8 | ||
|
|
510cc1b916 | ||
|
|
e7859fefb1 | ||
|
|
46a356cc40 | ||
|
|
bd56174870 | ||
|
|
965bb5f634 | ||
|
|
7937d5f802 | ||
|
|
f1373a63c3 | ||
|
|
e0d7625c39 |
@@ -0,0 +1,350 @@
|
||||
name: build desktop modules
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel to ship renderer modules on. The live shell on that channel stays as it is.
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- canary
|
||||
- stable
|
||||
default: canary
|
||||
build_version:
|
||||
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
allow_shell_drift:
|
||||
description: Publish even though shell sources changed since the live shell was built. Only when the renderer does not depend on those changes.
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-modules-${{ inputs.channel }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
|
||||
|
||||
jobs:
|
||||
meta:
|
||||
name: Resolve build metadata
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
pub_date: ${{ steps.meta.outputs.pub_date }}
|
||||
channel: ${{ steps.meta.outputs.channel }}
|
||||
build_channel: ${{ steps.meta.outputs.build_channel }}
|
||||
source_sha: ${{ steps.meta.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: read
|
||||
- name: Set metadata
|
||||
id: meta
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
FLUXER_BUILD_VERSION: ${{ inputs.build_version }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_metadata
|
||||
--channel "${{ inputs.channel }}"
|
||||
|
||||
shared_assets:
|
||||
name: Build shared renderer assets
|
||||
needs:
|
||||
- meta
|
||||
runs-on: ubuntu-24.04
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.channel }}
|
||||
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout CI helpers
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
path: _ci
|
||||
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
path: source
|
||||
|
||||
- name: Set up Rust toolchain (renderer wasm)
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Set workdir (Unix)
|
||||
env:
|
||||
SUBST_TARGET: ${{ github.workspace }}/source
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_workdir_unix
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Set up pnpm
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step setup_pnpm
|
||||
|
||||
- name: Resolve pnpm store path (Unix)
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step resolve_pnpm_store_unix
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-shared-renderer-pnpm-store-
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-shared-renderer-cargo-registry-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step install_dependencies
|
||||
|
||||
- name: Update version
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step update_version
|
||||
|
||||
- name: Set build channel
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_build_channel
|
||||
|
||||
- name: Build shared renderer assets
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_shared_assets
|
||||
|
||||
- name: Prepare shared renderer artifact
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_shared_assets
|
||||
|
||||
- name: Upload shared renderer artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: source/desktop-shared-assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
- name: Split renderer into desktop modules
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step split_modules
|
||||
|
||||
- name: Pack desktop modules
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step pack_modules
|
||||
|
||||
- name: Upload desktop module packages
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: |
|
||||
source/desktop-modules/classification.json
|
||||
source/desktop-modules/*/module.json
|
||||
source/desktop-modules/*/package.br
|
||||
source/desktop-modules/*/package.br.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
assemble:
|
||||
name: Assemble the modules-only release
|
||||
if: ${{ !cancelled() && needs.shared_assets.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- shared_assets
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Download desktop module packages
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: desktop-modules
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: read
|
||||
- name: Check the live shell runs these modules
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
ALLOW_SHELL_DRIFT: ${{ inputs.allow_shell_drift }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step check_shell_drift
|
||||
|
||||
- name: Build the modules-only manifests
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_modules_only_manifest
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_modules_release_assets
|
||||
|
||||
- name: Upload GitHub release assets
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: fluxer-desktop-modules-release-assets
|
||||
path: release_assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
publish_release:
|
||||
name: Publish GitHub modules-only release
|
||||
if: ${{ !cancelled() && needs.assemble.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- assemble
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Download GitHub release assets
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: fluxer-desktop-modules-release-assets
|
||||
path: release_assets
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: write
|
||||
- name: Publish GitHub modules-only release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_args=(
|
||||
release publish
|
||||
--component "fluxer-desktop-${CHANNEL}"
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
--asset-dir release_assets
|
||||
)
|
||||
if [[ "${CHANNEL}" == "canary" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||
@@ -6266,6 +6266,72 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/bot-status": {
|
||||
"put": {
|
||||
"operationId": "set_admin_user_bot_status",
|
||||
"summary": "Set user bot status",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserBotStatusRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/change-log": {
|
||||
"get": {
|
||||
"operationId": "list_admin_user_change_log",
|
||||
@@ -7967,6 +8033,72 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/system-status": {
|
||||
"put": {
|
||||
"operationId": "set_admin_user_system_status",
|
||||
"summary": "Set user system status",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserSystemStatusRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/traits": {
|
||||
"put": {
|
||||
"operationId": "set_admin_user_traits",
|
||||
@@ -9413,6 +9545,13 @@
|
||||
},
|
||||
"required": ["traits"]
|
||||
},
|
||||
"AdminUserSystemStatusRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"system": {"type": "boolean", "description": "Whether the user should be marked as a system user"}
|
||||
},
|
||||
"required": ["system"]
|
||||
},
|
||||
"AdminStorePurchaseListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9877,6 +10016,11 @@
|
||||
"required": ["entries", "next_page_token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AdminUserBotStatusRequest": {
|
||||
"type": "object",
|
||||
"properties": {"bot": {"type": "boolean", "description": "Whether the user should be marked as a bot"}},
|
||||
"required": ["bot"]
|
||||
},
|
||||
"AdminUserBanNoteRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9991,7 +10135,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions to assign"
|
||||
@@ -12892,7 +13036,7 @@
|
||||
},
|
||||
"acls": {
|
||||
"description": "Replacement list of access control permissions for the key",
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"}
|
||||
}
|
||||
@@ -12910,7 +13054,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12940,7 +13084,7 @@
|
||||
"maximum": 365
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12961,7 +13105,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12974,7 +13118,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 105,
|
||||
"maxItems": 106,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"description": "Every admin access control permission the admin API recognises"
|
||||
@@ -13076,6 +13220,7 @@
|
||||
"user:view:email",
|
||||
"user:view:ip",
|
||||
"user:temp_ban",
|
||||
"user:update:bot_status",
|
||||
"user:update:dob",
|
||||
"user:update:email",
|
||||
"user:update:flags",
|
||||
@@ -16123,7 +16268,7 @@
|
||||
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {"maxItems": 105, "type": "array", "items": {"type": "string"}},
|
||||
"acls": {"maxItems": 106, "type": "array", "items": {"type": "string"}},
|
||||
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
|
||||
"has_totp": {"type": "boolean"},
|
||||
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
|
||||
|
||||
@@ -90,6 +90,7 @@ pub const USER_VIEW_DOB: &str = "user:view:dob";
|
||||
pub const USER_VIEW_EMAIL: &str = "user:view:email";
|
||||
pub const USER_VIEW_IP: &str = "user:view:ip";
|
||||
pub const USER_TEMP_BAN: &str = "user:temp_ban";
|
||||
pub const USER_UPDATE_BOT_STATUS: &str = "user:update:bot_status";
|
||||
pub const USER_UPDATE_DOB: &str = "user:update:dob";
|
||||
pub const USER_UPDATE_EMAIL: &str = "user:update:email";
|
||||
pub const USER_UPDATE_FLAGS: &str = "user:update:flags";
|
||||
@@ -196,6 +197,7 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
USER_VIEW_EMAIL,
|
||||
USER_VIEW_IP,
|
||||
USER_TEMP_BAN,
|
||||
USER_UPDATE_BOT_STATUS,
|
||||
USER_UPDATE_DOB,
|
||||
USER_UPDATE_EMAIL,
|
||||
USER_UPDATE_FLAGS,
|
||||
|
||||
@@ -306,6 +306,28 @@ impl AdminApiClient {
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_admin_user_bot_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_admin_user_system_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn change_username(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -178,6 +178,22 @@ pub async fn dispatch(
|
||||
"Failed to clear user fields",
|
||||
)
|
||||
}
|
||||
"set_bot_status" => {
|
||||
let val = form.bool_value("bot");
|
||||
DispatchOutcome::from_result(
|
||||
client.set_bot_status(user_id, val).await,
|
||||
"Bot status updated successfully",
|
||||
"Failed to update bot status",
|
||||
)
|
||||
}
|
||||
"set_system_status" => {
|
||||
let val = form.bool_value("system");
|
||||
DispatchOutcome::from_result(
|
||||
client.set_system_status(user_id, val).await,
|
||||
"System status updated successfully",
|
||||
"Failed to update system status",
|
||||
)
|
||||
}
|
||||
"change_username" => {
|
||||
let Some(username) = get("username") else {
|
||||
return DispatchOutcome::error("Username is required");
|
||||
|
||||
@@ -52,6 +52,7 @@ pub fn account_tab(
|
||||
(sessions_card(config, sessions))
|
||||
(quick_actions_card(base, user, csrf_token, username_sign_in, can_revoke_recovery_kit))
|
||||
(clear_fields_card(base, user, csrf_token))
|
||||
(user_status_card(base, user, csrf_token))
|
||||
(security_actions_card(base, user, csrf_token))
|
||||
(webauthn_credentials_card(base, user, webauthn_credentials, csrf_token))
|
||||
}
|
||||
@@ -310,6 +311,19 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn user_status_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
let is_bot = user.bot;
|
||||
let is_sys = user.system;
|
||||
html! {
|
||||
(card_with_header("User Status", html! {
|
||||
div class="grid grid-cols-1 gap-4 md:grid-cols-2" {
|
||||
(status_toggle(base, &user.id, "set_bot_status", is_bot, "bot", csrf_token))
|
||||
(status_toggle(base, &user.id, "set_system_status", is_sys, "system", csrf_token))
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn security_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Security Actions", html! {
|
||||
@@ -450,3 +464,40 @@ fn action_form(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn status_toggle(
|
||||
base: &str,
|
||||
uid: &str,
|
||||
action: &str,
|
||||
active: bool,
|
||||
kind: &str,
|
||||
csrf: &str,
|
||||
) -> Markup {
|
||||
let status_val = if active { "false" } else { "true" };
|
||||
let label = format!(
|
||||
"{} {} Status",
|
||||
if active { "Remove" } else { "Set" },
|
||||
capitalize(kind)
|
||||
);
|
||||
let action_url = format!("{base}/users/{uid}?action={action}&status={status_val}&tab=account");
|
||||
html! {
|
||||
form method="post"
|
||||
action=(&action_url)
|
||||
hx-post=(&action_url)
|
||||
hx-target="#flash-container"
|
||||
hx-swap="none"
|
||||
hx-push-url="false" {
|
||||
(csrf_input(csrf))
|
||||
input type="hidden" name=(kind) value=(status_val);
|
||||
button type="submit" class=(BTN_CLS) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().chain(c).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +437,11 @@ fn acls_card(
|
||||
(flag_checkbox("acls[]", item.to_string(), item, checked, true))
|
||||
}
|
||||
}
|
||||
@for item in &user.acls {
|
||||
@if !acl::ALL_ACLS.iter().any(|known| known == &item.as_str()) {
|
||||
input type="hidden" name="acls[]" value=(item);
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save ACLs"))
|
||||
}))
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AdminUserAclsRequest,
|
||||
AdminUserBanNoteRequest,
|
||||
AdminUserBanRequest,
|
||||
AdminUserBotStatusRequest,
|
||||
AdminUserChangeLogQuery,
|
||||
AdminUserClearFieldsRequest,
|
||||
AdminUserDeletionCancelRequest,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
AdminUserPremiumFlagsUpdateRequest,
|
||||
AdminUserRelationshipCategoryQuery,
|
||||
AdminUserRelationshipParam,
|
||||
AdminUserSystemStatusRequest,
|
||||
AdminUsersMeResponse,
|
||||
AdminUserTraitsRequest,
|
||||
AdminUserUnbanRequest,
|
||||
@@ -601,6 +603,70 @@ export function UserAdminController(app: HonoApp) {
|
||||
);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/users/:user_id/bot-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminUserBotStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_admin_user_bot_status',
|
||||
summary: 'Set user bot status',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.userService.profileService.setUserBotStatus(
|
||||
{user_id: userId, ...ctx.req.valid('json')},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
adminUserAcls,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/users/:user_id/system-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminUserSystemStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_admin_user_system_status',
|
||||
summary: 'Set user system status',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.userService.profileService.setUserSystemStatus(
|
||||
{user_id: userId, ...ctx.req.valid('json')},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
adminUserAcls,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
'/admin/users/:user_id/username',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
|
||||
@@ -15,6 +15,9 @@ import type {User} from '@app/api/models/User';
|
||||
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
|
||||
import {assertNoDiscriminatorChange, reserveUsername, type UsernameReservation} from '@app/api/user/UniqueUsernames';
|
||||
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
@@ -22,6 +25,8 @@ import type {
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {types} from 'cassandra-driver';
|
||||
@@ -104,6 +109,75 @@ export class AdminUserProfileService {
|
||||
};
|
||||
}
|
||||
|
||||
async setUserBotStatus(
|
||||
data: SetUserBotStatusRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
) {
|
||||
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (data.bot && user.acls.size > 0) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
const updates: Record<string, boolean> = {bot: data.bot};
|
||||
if (!data.bot) {
|
||||
updates['system'] = false;
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_bot_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['bot', data.bot.toString()]]),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
};
|
||||
}
|
||||
|
||||
async setUserSystemStatus(
|
||||
data: SetUserSystemStatusRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
) {
|
||||
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (data.system && !user.isBot) {
|
||||
throw InputValidationError.fromCode(
|
||||
'system',
|
||||
ValidationErrorCodes.USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER,
|
||||
);
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {system: data.system}, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_system_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['system', data.system.toString()]]),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyUserEmail(
|
||||
data: VerifyUserEmailRequest,
|
||||
adminUserId: UserID,
|
||||
|
||||
@@ -78,6 +78,7 @@ const adminEndpoints: Array<AdminEndpointCase> = [
|
||||
{method: 'GET', path: '/admin/users/1/webauthn-credentials', requiredACL: 'user:update:mfa'},
|
||||
{method: 'DELETE', path: '/admin/users/1/webauthn-credentials/credential', requiredACL: 'user:update:mfa'},
|
||||
{method: 'DELETE', path: '/admin/users/1/profile-fields', requiredACL: 'user:update:profile'},
|
||||
{method: 'PUT', path: '/admin/users/1/bot-status', requiredACL: 'user:update:bot_status'},
|
||||
{method: 'PUT', path: '/admin/users/1/acls', requiredACL: 'acl:set:user'},
|
||||
{method: 'PUT', path: '/admin/users/1/deletion', requiredACL: 'user:delete'},
|
||||
{method: 'POST', path: '/admin/users/1/avatar-block', requiredACL: 'ban:avatar_hash:add'},
|
||||
|
||||
@@ -20,6 +20,8 @@ const MUTATIONS: Array<{verb: 'put' | 'patch' | 'delete'; path: string; acl: str
|
||||
{verb: 'put', path: 'ban', acl: AdminACLs.USER_TEMP_BAN, body: {duration_hours: 1, reason: 'test'}},
|
||||
{verb: 'put', path: 'deletion', acl: AdminACLs.USER_DELETE, body: {delay_days: 1}},
|
||||
{verb: 'delete', path: 'profile-fields', acl: AdminACLs.USER_UPDATE_PROFILE, body: {fields: ['bio']}},
|
||||
{verb: 'put', path: 'bot-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {bot: true}},
|
||||
{verb: 'put', path: 'system-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {system: true}},
|
||||
];
|
||||
|
||||
const CASES = SYNTHETIC_USER_IDS.flatMap((userId) =>
|
||||
|
||||
@@ -191,6 +191,39 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/bot-status',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/bot-status`, body: {bot: true}},
|
||||
expected: {
|
||||
action: 'set_bot_status',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {bot: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/system-status',
|
||||
async prepare(context) {
|
||||
const target = await createTestAccount(context.harness);
|
||||
await adminBuilder(context).put(`/admin/users/${target.userId}/bot-status`).body({bot: true}).execute();
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/system-status`, body: {system: true}},
|
||||
expected: {
|
||||
action: 'set_system_status',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {system: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/users/:user_id/username',
|
||||
|
||||
@@ -29,7 +29,9 @@ import {
|
||||
HandoffCancelRequest,
|
||||
HandoffCodeParam,
|
||||
HandoffCompleteRequest,
|
||||
HandoffCompleteResponse,
|
||||
HandoffInfoResponse,
|
||||
HandoffInitiateRequest,
|
||||
HandoffInitiateResponse,
|
||||
HandoffStatusRequest,
|
||||
HandoffStatusResponse,
|
||||
@@ -609,6 +611,7 @@ export function AuthController(app: HonoApp) {
|
||||
app.post(
|
||||
'/auth/handoff/initiate',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_INITIATE),
|
||||
Validator('json', HandoffInitiateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'initiate_handoff',
|
||||
summary: 'Initiate handoff',
|
||||
@@ -617,10 +620,15 @@ export function AuthController(app: HonoApp) {
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
|
||||
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking. A desktop app that registers a return deep link can be signed in without the user typing the code.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
|
||||
return ctx.json(
|
||||
await ctx.get('authRequestService').initiateHandoff({
|
||||
request: ctx.req.raw,
|
||||
returnUri: ctx.req.valid('json')?.return_uri ?? null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -656,23 +664,47 @@ export function AuthController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'complete_handoff',
|
||||
summary: 'Complete handoff',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
responseSchema: HandoffCompleteResponse,
|
||||
statusCode: [200, 204],
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description: 'Complete the handoff process and authenticate on the target device using the handoff code.',
|
||||
description:
|
||||
'Complete the handoff process and authenticate on the target device using the handoff code. With the deep_link return method, responds with the deep link that hands the one-time grant back to the initiating app. Otherwise responds with no content.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const clientIp = requireClientIp(ctx.req.raw, {
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
});
|
||||
await ctx.get('authRequestService').completeHandoff({
|
||||
const response = await ctx.get('authRequestService').completeHandoff({
|
||||
data: ctx.req.valid('json'),
|
||||
clientIp,
|
||||
authToken: ctx.get('authToken') ?? undefined,
|
||||
approverOrigin: ctx.req.header('origin'),
|
||||
});
|
||||
return response ? ctx.json(response) : ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/handoff/:code/deny',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_DENY),
|
||||
Validator('param', HandoffCodeParam),
|
||||
OpenAPI({
|
||||
operationId: 'deny_handoff',
|
||||
summary: 'Deny handoff',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Decline a handoff request after looking it up. The initiating device sees the denied status and the code can no longer be approved.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const clientIp = requireClientIp(ctx.req.raw, {
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
});
|
||||
await ctx.get('authRequestService').denyHandoff({code: ctx.req.valid('param').code, clientIp});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
@@ -726,6 +758,7 @@ export function AuthController(app: HonoApp) {
|
||||
code: ctx.req.valid('param').code,
|
||||
clientIp,
|
||||
pollSecret: ctx.req.valid('json').poll_secret,
|
||||
grant: ctx.req.valid('json').grant,
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
EmailRevertRequest,
|
||||
ForgotPasswordRequest,
|
||||
HandoffCompleteRequest,
|
||||
HandoffCompleteResponse,
|
||||
HandoffInfoResponse,
|
||||
HandoffInitiateResponse,
|
||||
HandoffStatusResponse,
|
||||
@@ -141,6 +142,7 @@ interface AuthLogoutAuthSessionsRequest {
|
||||
|
||||
interface AuthHandoffInitiateRequest {
|
||||
request: Request;
|
||||
returnUri?: string | null;
|
||||
}
|
||||
|
||||
interface AuthHandoffInfoRequest {
|
||||
@@ -152,6 +154,7 @@ interface AuthHandoffStatusRequest {
|
||||
code: string;
|
||||
clientIp: string;
|
||||
pollSecret?: string;
|
||||
grant?: string;
|
||||
}
|
||||
|
||||
interface AuthHandoffCancelRequest {
|
||||
@@ -339,16 +342,18 @@ export class AuthRequestService {
|
||||
return {available: !(await isUsernameTaken(this.apiContext.services.users, username))};
|
||||
}
|
||||
|
||||
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
async initiateHandoff({request, returnUri}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
|
||||
const result = await this.desktopHandoffService.initiateHandoff({
|
||||
origin,
|
||||
initiatorOrigin: request.headers.get('origin'),
|
||||
returnUri,
|
||||
});
|
||||
return {
|
||||
code: result.code,
|
||||
expires_at: result.expiresAt.toISOString(),
|
||||
poll_secret: result.pollSecret,
|
||||
return_method: result.returnMethod,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -376,16 +381,26 @@ export class AuthRequestService {
|
||||
country: geo.countryName,
|
||||
},
|
||||
},
|
||||
return_method: info.returnMethod,
|
||||
};
|
||||
}
|
||||
|
||||
async completeHandoff({data, clientIp, authToken, approverOrigin}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
async denyHandoff({code, clientIp}: AuthHandoffInfoRequest): Promise<void> {
|
||||
await this.desktopHandoffService.denyHandoff(code, clientIp);
|
||||
}
|
||||
|
||||
async completeHandoff({
|
||||
data,
|
||||
clientIp,
|
||||
authToken,
|
||||
approverOrigin,
|
||||
}: AuthHandoffCompleteRequest): Promise<HandoffCompleteResponse | null> {
|
||||
const sessionToken = data.token ?? authToken;
|
||||
if (!sessionToken) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
let createdToken: string | null = null;
|
||||
const {initiatorOrigin} = await this.desktopHandoffService.completeHandoff(
|
||||
const {initiatorOrigin, returnUrl} = await this.desktopHandoffService.completeHandoff(
|
||||
data.code,
|
||||
async (origin) => {
|
||||
const created = await AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
|
||||
@@ -397,10 +412,12 @@ export class AuthRequestService {
|
||||
return created;
|
||||
},
|
||||
clientIp,
|
||||
data.return_method,
|
||||
);
|
||||
if (createdToken !== null) {
|
||||
await this.recordPushSessionPredecessor(createdToken, sessionToken, initiatorOrigin, approverOrigin);
|
||||
}
|
||||
return returnUrl ? {return_url: returnUrl} : null;
|
||||
}
|
||||
|
||||
private async recordPushSessionPredecessor(
|
||||
@@ -428,8 +445,13 @@ export class AuthRequestService {
|
||||
}
|
||||
}
|
||||
|
||||
async getHandoffStatus({code, clientIp, pollSecret}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
|
||||
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret);
|
||||
async getHandoffStatus({
|
||||
code,
|
||||
clientIp,
|
||||
pollSecret,
|
||||
grant,
|
||||
}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
|
||||
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret, grant);
|
||||
return {
|
||||
status: result.status,
|
||||
token: result.token,
|
||||
|
||||
@@ -21,6 +21,7 @@ const MAX_FAILED_ATTEMPTS = 5;
|
||||
const ATTEMPT_TTL_SECONDS = 900;
|
||||
const MAX_INFO_LOOKUPS = 3;
|
||||
const POLL_SECRET_BYTES = 32;
|
||||
const GRANT_BYTES = 32;
|
||||
|
||||
interface HandoffData {
|
||||
createdAt: number;
|
||||
@@ -28,14 +29,19 @@ interface HandoffData {
|
||||
initiatorOrigin?: string | null;
|
||||
infoLookupCount: number;
|
||||
pollSecretHash: string;
|
||||
returnUri?: string | null;
|
||||
denied?: boolean;
|
||||
}
|
||||
|
||||
interface HandoffTokenData {
|
||||
token: string;
|
||||
userId: string;
|
||||
pollSecretHash: string;
|
||||
grantHash?: string;
|
||||
}
|
||||
|
||||
export type DesktopHandoffReturnMethod = 'deep_link' | 'code';
|
||||
|
||||
interface HandoffApproverData {
|
||||
approvedAt: number;
|
||||
}
|
||||
@@ -62,15 +68,19 @@ function requireNormalizedHandoffCode(code: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function generatePollSecret(): string {
|
||||
return randomBytes(POLL_SECRET_BYTES).toString('base64url');
|
||||
function generateSecret(byteLength: number): string {
|
||||
return randomBytes(byteLength).toString('base64url');
|
||||
}
|
||||
|
||||
function hashPollSecret(secret: string): string {
|
||||
return createHash('sha256').update(secret).digest('hex');
|
||||
}
|
||||
|
||||
function pollSecretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
|
||||
function resolveReturnMethod(handoffData: HandoffData): DesktopHandoffReturnMethod {
|
||||
return handoffData.returnUri ? 'deep_link' : 'code';
|
||||
}
|
||||
|
||||
function secretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
|
||||
if (!presented || !storedHash) {
|
||||
return false;
|
||||
}
|
||||
@@ -85,32 +95,44 @@ function pollSecretMatches(presented: string | undefined, storedHash: string | u
|
||||
export class DesktopHandoffService {
|
||||
constructor(private readonly apiContext: ApiContext) {}
|
||||
|
||||
async initiateHandoff(args: {origin: SessionOrigin; initiatorOrigin?: string | null}): Promise<{
|
||||
async initiateHandoff(args: {
|
||||
origin: SessionOrigin;
|
||||
initiatorOrigin?: string | null;
|
||||
returnUri?: string | null;
|
||||
}): Promise<{
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
pollSecret: string;
|
||||
returnMethod: DesktopHandoffReturnMethod;
|
||||
}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = generateNormalizedHandoffCode();
|
||||
const pollSecret = generatePollSecret();
|
||||
const pollSecret = generateSecret(POLL_SECRET_BYTES);
|
||||
const handoffData: HandoffData = {
|
||||
createdAt: Date.now(),
|
||||
origin: args.origin,
|
||||
initiatorOrigin: args.initiatorOrigin ?? null,
|
||||
infoLookupCount: 0,
|
||||
pollSecretHash: hashPollSecret(pollSecret),
|
||||
returnUri: args.returnUri ?? null,
|
||||
};
|
||||
const expirySeconds = seconds('5 minutes');
|
||||
await cache.set(`${HANDOFF_CODE_PREFIX}${normalizedCode}`, handoffData, expirySeconds);
|
||||
const expiresAt = new Date(Date.now() + ms('5 minutes'));
|
||||
return {code: formatDesktopHandoffCode(normalizedCode), expiresAt, pollSecret};
|
||||
return {
|
||||
code: formatDesktopHandoffCode(normalizedCode),
|
||||
expiresAt,
|
||||
pollSecret,
|
||||
returnMethod: resolveReturnMethod(handoffData),
|
||||
};
|
||||
}
|
||||
|
||||
async completeHandoff(
|
||||
code: string,
|
||||
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
|
||||
approverIp: string,
|
||||
): Promise<{initiatorOrigin: string | null}> {
|
||||
returnMethod: DesktopHandoffReturnMethod = 'code',
|
||||
): Promise<{initiatorOrigin: string | null; returnUrl: string | null}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = requireNormalizedHandoffCode(code);
|
||||
await this.checkAttemptLimit(approverIp);
|
||||
@@ -120,10 +142,13 @@ export class DesktopHandoffService {
|
||||
throw new InvalidHandoffCodeError();
|
||||
}
|
||||
const handoffData = await cache.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
if (!handoffData) {
|
||||
if (!handoffData || handoffData.denied) {
|
||||
await this.recordFailedAttempt(approverIp);
|
||||
throw new InvalidHandoffCodeError();
|
||||
}
|
||||
if (returnMethod === 'deep_link' && !handoffData.returnUri) {
|
||||
throw new InvalidHandoffCodeError();
|
||||
}
|
||||
const remainingSeconds = Math.max(
|
||||
0,
|
||||
seconds('5 minutes') - Math.floor((Date.now() - handoffData.createdAt) / 1000),
|
||||
@@ -132,15 +157,22 @@ export class DesktopHandoffService {
|
||||
throw new HandoffCodeExpiredError();
|
||||
}
|
||||
const {token, userId} = await createTokenData(handoffData.origin);
|
||||
const grant = returnMethod === 'deep_link' ? generateSecret(GRANT_BYTES) : null;
|
||||
const tokenData: HandoffTokenData = {
|
||||
token,
|
||||
userId,
|
||||
pollSecretHash: handoffData.pollSecretHash,
|
||||
...(grant ? {grantHash: hashPollSecret(grant)} : {}),
|
||||
};
|
||||
await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
|
||||
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
|
||||
return {initiatorOrigin: handoffData.initiatorOrigin ?? null};
|
||||
let returnUrl: string | null = null;
|
||||
if (grant && handoffData.returnUri) {
|
||||
const params = new URLSearchParams({code: formatDesktopHandoffCode(normalizedCode), grant});
|
||||
returnUrl = `${handoffData.returnUri}?${params.toString()}`;
|
||||
}
|
||||
return {initiatorOrigin: handoffData.initiatorOrigin ?? null, returnUrl};
|
||||
}
|
||||
|
||||
async getHandoffInfo(
|
||||
@@ -149,13 +181,14 @@ export class DesktopHandoffService {
|
||||
): Promise<{
|
||||
status: 'pending' | 'expired';
|
||||
origin?: SessionOrigin;
|
||||
returnMethod?: DesktopHandoffReturnMethod;
|
||||
}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = requireNormalizedHandoffCode(code);
|
||||
await this.checkAttemptLimit(approverIp);
|
||||
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
|
||||
const handoffData = await cache.get<HandoffData>(codeKey);
|
||||
if (!handoffData) {
|
||||
if (!handoffData || handoffData.denied) {
|
||||
await this.recordFailedAttempt(approverIp);
|
||||
return {status: 'expired'};
|
||||
}
|
||||
@@ -172,15 +205,35 @@ export class DesktopHandoffService {
|
||||
{approvedAt: Date.now()},
|
||||
remainingTtl > 0 ? remainingTtl : seconds('5 minutes'),
|
||||
);
|
||||
return {status: 'pending', origin: handoffData.origin};
|
||||
return {status: 'pending', origin: handoffData.origin, returnMethod: resolveReturnMethod(handoffData)};
|
||||
}
|
||||
|
||||
async denyHandoff(code: string, approverIp: string): Promise<void> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = requireNormalizedHandoffCode(code);
|
||||
await this.checkAttemptLimit(approverIp);
|
||||
const approverKey = `${HANDOFF_APPROVER_PREFIX}${normalizedCode}`;
|
||||
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
|
||||
const storedApprover = await cache.get<HandoffApproverData>(approverKey);
|
||||
const handoffData = await cache.get<HandoffData>(codeKey);
|
||||
if (!storedApprover || !handoffData) {
|
||||
await this.recordFailedAttempt(approverIp);
|
||||
throw new InvalidHandoffCodeError();
|
||||
}
|
||||
const remainingTtl = await cache.ttl(codeKey);
|
||||
if (remainingTtl > 0) {
|
||||
await cache.set(codeKey, {...handoffData, denied: true}, remainingTtl);
|
||||
}
|
||||
await cache.delete(approverKey);
|
||||
}
|
||||
|
||||
async getHandoffStatus(
|
||||
code: string,
|
||||
pollerIp: string,
|
||||
pollSecret: string | undefined,
|
||||
grant?: string,
|
||||
): Promise<{
|
||||
status: 'pending' | 'completed' | 'expired';
|
||||
status: 'pending' | 'completed' | 'denied' | 'expired';
|
||||
token?: string;
|
||||
userId?: string;
|
||||
}> {
|
||||
@@ -190,10 +243,19 @@ export class DesktopHandoffService {
|
||||
const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`;
|
||||
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
|
||||
if (tokenData) {
|
||||
if (!pollSecretMatches(pollSecret, tokenData.pollSecretHash)) {
|
||||
if (!secretMatches(pollSecret, tokenData.pollSecretHash)) {
|
||||
await this.recordFailedAttempt(pollerIp);
|
||||
return {status: 'pending'};
|
||||
}
|
||||
if (tokenData.grantHash) {
|
||||
if (!grant) {
|
||||
return {status: 'pending'};
|
||||
}
|
||||
if (!secretMatches(grant, tokenData.grantHash)) {
|
||||
await this.recordFailedAttempt(pollerIp);
|
||||
return {status: 'pending'};
|
||||
}
|
||||
}
|
||||
await cache.delete(tokenKey);
|
||||
return {
|
||||
status: 'completed',
|
||||
@@ -203,7 +265,7 @@ export class DesktopHandoffService {
|
||||
}
|
||||
const handoffData = await cache.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
if (handoffData) {
|
||||
return {status: 'pending'};
|
||||
return {status: handoffData.denied ? 'denied' : 'pending'};
|
||||
}
|
||||
return {status: 'expired'};
|
||||
}
|
||||
@@ -216,7 +278,7 @@ export class DesktopHandoffService {
|
||||
const handoffData = await cache.get<HandoffData>(codeKey);
|
||||
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
|
||||
const storedHash = handoffData?.pollSecretHash ?? tokenData?.pollSecretHash;
|
||||
if (!pollSecretMatches(pollSecret, storedHash)) {
|
||||
if (!secretMatches(pollSecret, storedHash)) {
|
||||
throw new InvalidHandoffCodeError();
|
||||
}
|
||||
await cache.delete(codeKey);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, loginAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface HandoffInitiateResponse {
|
||||
code: string;
|
||||
poll_secret: string;
|
||||
return_method: 'deep_link' | 'code';
|
||||
}
|
||||
|
||||
interface HandoffInfoResponse {
|
||||
status: 'pending' | 'expired';
|
||||
return_method?: 'deep_link' | 'code';
|
||||
}
|
||||
|
||||
interface HandoffCompleteResponse {
|
||||
return_url: string;
|
||||
}
|
||||
|
||||
interface HandoffStatusResponse {
|
||||
status: 'pending' | 'completed' | 'denied' | 'expired';
|
||||
token?: string | null;
|
||||
user_id?: string | null;
|
||||
}
|
||||
|
||||
const RETURN_URI = 'fluxer-canary://handoff';
|
||||
|
||||
describe('Auth desktop handoff deep link return', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function initiate(body: Record<string, unknown> | null): Promise<HandoffInitiateResponse> {
|
||||
return createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
|
||||
.post('/auth/handoff/initiate')
|
||||
.body(body)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function readStatus(code: string, body: Record<string, unknown>): Promise<HandoffStatusResponse> {
|
||||
return createBuilderWithoutAuth<HandoffStatusResponse>(harness)
|
||||
.post(`/auth/handoff/${code}/status`)
|
||||
.body(body)
|
||||
.execute();
|
||||
}
|
||||
|
||||
it('releases the token only to the app that presents both the poll secret and the grant', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
const initResp = await initiate({return_uri: RETURN_URI});
|
||||
expect(initResp.return_method).toBe('deep_link');
|
||||
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
|
||||
.get(`/auth/handoff/${initResp.code}/info`)
|
||||
.execute();
|
||||
expect(info.return_method).toBe('deep_link');
|
||||
const completed = await createBuilderWithoutAuth<HandoffCompleteResponse>(harness)
|
||||
.post('/auth/handoff/complete')
|
||||
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
const returnUrl = new URL(completed.return_url);
|
||||
expect(`${returnUrl.protocol}//${returnUrl.host}`).toBe(RETURN_URI);
|
||||
expect(returnUrl.searchParams.get('code')).toBe(initResp.code);
|
||||
const grant = returnUrl.searchParams.get('grant');
|
||||
expect(grant).toBeTruthy();
|
||||
const withoutGrant = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
|
||||
expect(withoutGrant.status).toBe('pending');
|
||||
expect(withoutGrant.token ?? null).toBeNull();
|
||||
const wrongGrant = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant: 'not-the-grant'});
|
||||
expect(wrongGrant.status).toBe('pending');
|
||||
const grantWithoutSecret = await readStatus(initResp.code, {poll_secret: 'not-the-secret', grant: grant!});
|
||||
expect(grantWithoutSecret.status).toBe('pending');
|
||||
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant: grant!});
|
||||
expect(released.status).toBe('completed');
|
||||
expect(released.token).toBeTruthy();
|
||||
expect(released.user_id).toBe(login.userId);
|
||||
});
|
||||
|
||||
it('keeps polling without a grant from counting as a failed attempt', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
const initResp = await initiate({return_uri: RETURN_URI});
|
||||
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
|
||||
const completed = await createBuilderWithoutAuth<HandoffCompleteResponse>(harness)
|
||||
.post('/auth/handoff/complete')
|
||||
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
|
||||
.execute();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const pending = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
|
||||
expect(pending.status).toBe('pending');
|
||||
}
|
||||
const grant = new URL(completed.return_url).searchParams.get('grant')!;
|
||||
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant});
|
||||
expect(released.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('still completes through the typed code when the app registered a deep link', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
const initResp = await initiate({return_uri: RETURN_URI});
|
||||
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/handoff/complete')
|
||||
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'code'})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
|
||||
expect(released.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('reports the code return method when the app did not register a deep link', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
const initResp = await initiate(null);
|
||||
expect(initResp.return_method).toBe('code');
|
||||
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
|
||||
.get(`/auth/handoff/${initResp.code}/info`)
|
||||
.execute();
|
||||
expect(info.return_method).toBe('code');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/handoff/complete')
|
||||
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects a return deep link outside the desktop app schemes', async () => {
|
||||
for (const returnUri of ['https://evil.example/handoff', 'fluxer://handoff/extra', 'javascript://handoff']) {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/handoff/initiate')
|
||||
.body({return_uri: returnUri})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
it('tells the app when the browser declines the request and blocks later approval', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
const initResp = await initiate({return_uri: RETURN_URI});
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/handoff/${initResp.code}/deny`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
|
||||
.execute();
|
||||
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
|
||||
await createBuilderWithoutAuth(harness).post(`/auth/handoff/${initResp.code}/deny`).expect(204).execute();
|
||||
const denied = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
|
||||
expect(denied.status).toBe('denied');
|
||||
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
|
||||
.get(`/auth/handoff/${initResp.code}/info`)
|
||||
.execute();
|
||||
expect(info.status).toBe('expired');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/handoff/complete')
|
||||
.body({code: initResp.code, token: login.token, user_id: login.userId})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -76,10 +76,9 @@ import type {
|
||||
} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas';
|
||||
import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
|
||||
const THREAD_CREATED_MESSAGE_MIN_SOURCE_AGE_MS = 5 * 60 * 1000;
|
||||
const THREAD_CREATED_MESSAGE_RECENT_WINDOW = 5;
|
||||
|
||||
function slowmodeThreadKey(parentId: ChannelID, userId: UserID): string {
|
||||
return `slowmode-thread:${parentId}:${userId}`;
|
||||
@@ -159,13 +158,22 @@ export class ThreadCreationService {
|
||||
}
|
||||
const starter = await this.buildStarterEvent(params.viewer, params.user.id, view, params.requestCache);
|
||||
if (starter) events.push(starter);
|
||||
if (Date.now() - snowflakeToDate(source.id).getTime() >= THREAD_CREATED_MESSAGE_MIN_SOURCE_AGE_MS) {
|
||||
if (!(await this.isAmongRecentMessages(parentAuth.channel.id, source.id))) {
|
||||
events.push(await this.createThreadCreatedMessage(parentAuth.channel, view, params.user.id));
|
||||
}
|
||||
await dispatchThreadEvents(this.ctx.gatewayService, view.state.guildId, events);
|
||||
return mapThreadToResponse(view);
|
||||
}
|
||||
|
||||
private async isAmongRecentMessages(channelId: ChannelID, messageId: MessageID): Promise<boolean> {
|
||||
const recent = await this.ctx.channelRepository.messages.listMessages(
|
||||
channelId,
|
||||
undefined,
|
||||
THREAD_CREATED_MESSAGE_RECENT_WINDOW,
|
||||
);
|
||||
return recent.some((message) => message.id === messageId);
|
||||
}
|
||||
|
||||
async authenticateParent(viewer: ThreadViewer, userId: UserID, channelId: ChannelID): Promise<AuthenticatedChannel> {
|
||||
return this.ctx.channelAuth.getChannelAuthenticated({userId, channelId, viewer});
|
||||
}
|
||||
|
||||
@@ -101,12 +101,11 @@ describe('DM Privacy Bidirectional Enforcement', () => {
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
it('blocks message from a non-friend guild member to a new account by default', async () => {
|
||||
it('allows message from a non-friend guild member to a new account by default', async () => {
|
||||
const sender = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, sender.token);
|
||||
await ensureSessionStarted(harness, target.token);
|
||||
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
|
||||
await createFriendship(harness, sender, target);
|
||||
const guild = await createGuild(harness, sender.token, 'Default Community');
|
||||
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
|
||||
@@ -116,8 +115,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
|
||||
await removeRelationship(harness, sender.token, target.userId);
|
||||
await createBuilder(harness, sender.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'default restricted target'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
|
||||
.body({content: 'default open target'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
it('blocks message when sender restricts the only mutual guild', async () => {
|
||||
|
||||
@@ -268,25 +268,28 @@ describe('thread routes', () => {
|
||||
expect(member?.flags).toBe(ThreadMemberFlags.HAS_INTERACTED);
|
||||
});
|
||||
|
||||
it('announces threads started from older messages in the parent without moving its last message', async () => {
|
||||
it('announces threads started from messages above the latest five without moving its last message', async () => {
|
||||
const s = await setup();
|
||||
const message = await sendMessage(harness, s.owner.token, s.channelId, 'old news');
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now + 6 * 60 * 1000);
|
||||
const pushedUp = await sendMessage(harness, s.owner.token, s.channelId, 'old news');
|
||||
const nearBottom = await sendMessage(harness, s.owner.token, s.channelId, 'recent news');
|
||||
let latest = nearBottom;
|
||||
for (let i = 0; i < 4; i++) latest = await sendMessage(harness, s.owner.token, s.channelId, `filler ${i}`);
|
||||
const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
await threadsRequest(harness, s.owner.token)
|
||||
.post(`/channels/${s.channelId}/messages/${message.id}/threads`)
|
||||
.body({name: 'Later'})
|
||||
.expect(201)
|
||||
.execute();
|
||||
for (const message of [nearBottom, pushedUp]) {
|
||||
await threadsRequest(harness, s.owner.token)
|
||||
.post(`/channels/${s.channelId}/messages/${message.id}/threads`)
|
||||
.body({name: message.content})
|
||||
.expect(201)
|
||||
.execute();
|
||||
}
|
||||
const announced = dispatch.mock.calls
|
||||
.map(([params]) => params.data as MessageResponse)
|
||||
.filter((data) => data.type === MessageTypes.THREAD_CREATED);
|
||||
expect(announced).toHaveLength(1);
|
||||
expect(announced[0]).toMatchObject({channel_id: s.channelId, content: 'Later'});
|
||||
expect(announced[0]!.message_reference?.channel_id).toBe(message.id);
|
||||
expect(announced[0]).toMatchObject({channel_id: s.channelId, content: 'old news'});
|
||||
expect(announced[0]!.message_reference?.channel_id).toBe(pushedUp.id);
|
||||
const parent = await repository.findUnique(createChannelID(BigInt(s.channelId)));
|
||||
expect(parent?.lastMessageId?.toString()).toBe(message.id);
|
||||
expect(parent?.lastMessageId?.toString()).toBe(latest.id);
|
||||
});
|
||||
|
||||
it('refuses a second thread on the same message', async () => {
|
||||
|
||||
@@ -274,6 +274,8 @@ export interface UserSettingsRow {
|
||||
synced_preferences: Nullish<string>;
|
||||
profile_privacy: Nullish<number>;
|
||||
default_share_voice_activity: Nullish<boolean>;
|
||||
privacy_setup_version: Nullish<number>;
|
||||
privacy_setup_completed_at: Nullish<Date>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
@@ -513,6 +515,8 @@ export const USER_SETTINGS_COLUMNS = [
|
||||
'synced_preferences',
|
||||
'profile_privacy',
|
||||
'default_share_voice_activity',
|
||||
'privacy_setup_version',
|
||||
'privacy_setup_completed_at',
|
||||
'version',
|
||||
] as const satisfies ReadonlyArray<keyof UserSettingsRow>;
|
||||
export const USER_GUILD_SETTINGS_COLUMNS = [
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const BRANDING_ASSET_FIELDS = ['icon_url', 'symbol_url', 'logo_url', 'wordmark_url', 'favicon_url'] as const;
|
||||
|
||||
export type BrandingAssetField = (typeof BRANDING_ASSET_FIELDS)[number];
|
||||
|
||||
const BRANDING_ASSET_REFERENCE = /^branding\/\d{1,20}\/(?:a_)?[0-9a-f]{8}\.[a-z0-9]{2,5}$/;
|
||||
const BRANDING_ASSET_PATH = /\/(branding\/\d{1,20}\/(?:a_)?[0-9a-f]{8}\.[a-z0-9]{2,5})$/;
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
return value.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
export function isBrandingAssetReference(value: string): boolean {
|
||||
return BRANDING_ASSET_REFERENCE.test(value);
|
||||
}
|
||||
|
||||
export function brandingAssetReferenceFromOwnUrl(value: string, mediaEndpoint: string): string | null {
|
||||
const prefix = `${trimTrailingSlashes(mediaEndpoint)}/`;
|
||||
if (!value.startsWith(prefix)) return null;
|
||||
const remainder = value.slice(prefix.length);
|
||||
return isBrandingAssetReference(remainder) ? remainder : null;
|
||||
}
|
||||
|
||||
export function brandingAssetReferenceFromAnyUrl(value: string): string | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.search !== '' || url.hash !== '') return null;
|
||||
return BRANDING_ASSET_PATH.exec(url.pathname)?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function brandingAssetStorageKey(reference: string): string {
|
||||
const slash = reference.lastIndexOf('/');
|
||||
const file = reference.slice(slash + 1);
|
||||
const hash = file.slice(0, file.indexOf('.')).replace(/^a_/u, '');
|
||||
return `${reference.slice(0, slash)}/${hash}`;
|
||||
}
|
||||
|
||||
export function toStoredBrandingAsset(
|
||||
value: string | null | undefined,
|
||||
mediaEndpoint: string,
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null) return value;
|
||||
return brandingAssetReferenceFromOwnUrl(value, mediaEndpoint) ?? value;
|
||||
}
|
||||
|
||||
export function resolveBrandingAsset(value: string | null, mediaEndpoint: string): string | null {
|
||||
if (value === null || !isBrandingAssetReference(value)) return value;
|
||||
return `${trimTrailingSlashes(mediaEndpoint)}/${value}`;
|
||||
}
|
||||
|
||||
export function mapBrandingAssets<T extends Partial<Record<BrandingAssetField, string | null | undefined>>>(
|
||||
branding: T,
|
||||
map: (value: string | null | undefined) => string | null | undefined,
|
||||
): T {
|
||||
const next = {...branding};
|
||||
for (const field of BRANDING_ASSET_FIELDS) {
|
||||
if (field in next) {
|
||||
next[field] = map(next[field]) as T[BrandingAssetField];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {createServer} from 'node:net';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
@@ -222,6 +223,55 @@ describe('InstanceConfigRepository', () => {
|
||||
expect(config.branding.product_name).toBe('Kept');
|
||||
});
|
||||
|
||||
it('stores uploaded branding assets as references and resolves them against the current media endpoint', async () => {
|
||||
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
|
||||
const repository = createRepository(new MockKVProvider());
|
||||
const media = Config.endpoints.media;
|
||||
const foreign = 'https://cdn.example.com/favicon.ico';
|
||||
|
||||
await repository.setAppPublicConfig({
|
||||
branding: {favicon_url: `${media}/branding/0/0123abcd.png`, logo_url: foreign},
|
||||
});
|
||||
|
||||
const stored = JSON.parse((await repository.getConfig(APP_PUBLIC_CONFIG_KEY)) ?? '{}');
|
||||
expect(stored.branding.favicon_url).toBe('branding/0/0123abcd.png');
|
||||
expect(stored.branding.logo_url).toBe(foreign);
|
||||
Config.endpoints.media = 'https://media.moved.example';
|
||||
try {
|
||||
const config = await repository.getAppPublicConfig();
|
||||
expect(config.branding.favicon_url).toBe('https://media.moved.example/branding/0/0123abcd.png');
|
||||
expect(config.branding.logo_url).toBe(foreign);
|
||||
} finally {
|
||||
Config.endpoints.media = media;
|
||||
}
|
||||
});
|
||||
|
||||
it('normalises legacy branding URLs from an old domain only when the object is ours', async () => {
|
||||
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
|
||||
const repository = createRepository(new MockKVProvider());
|
||||
await repository.setConfig(
|
||||
APP_PUBLIC_CONFIG_KEY,
|
||||
JSON.stringify({
|
||||
branding: {
|
||||
favicon_url: 'https://old.example/media/branding/0/a_0123abcd.gif',
|
||||
icon_url: 'https://other.example/branding/0/89abcdef.png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const storage = {
|
||||
getObjectMetadata: vi.fn(async (_bucket: string, key: string) =>
|
||||
key === 'branding/0/0123abcd' ? {contentLength: 1, contentType: 'image/gif'} : null,
|
||||
),
|
||||
};
|
||||
|
||||
expect(await repository.normalizeStoredBrandingAssets(storage as never)).toBe(1);
|
||||
expect(await repository.normalizeStoredBrandingAssets(storage as never)).toBe(0);
|
||||
|
||||
const config = await repository.getAppPublicConfig();
|
||||
expect(config.branding.favicon_url).toBe(`${Config.endpoints.media}/branding/0/a_0123abcd.gif`);
|
||||
expect(config.branding.icon_url).toBe('https://other.example/branding/0/89abcdef.png');
|
||||
});
|
||||
|
||||
it('keeps valid stored instance policy flags when one field is invalid', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
@@ -7,12 +7,23 @@ import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/datab
|
||||
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
|
||||
import {syncChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import {
|
||||
type AccountIdentity,
|
||||
resolveAccountIdentity,
|
||||
type StoredAccountIdentity,
|
||||
setCachedAccountIdentity,
|
||||
} from '@app/api/instance/AccountIdentityModeCache';
|
||||
import {
|
||||
BRANDING_ASSET_FIELDS,
|
||||
type BrandingAssetField,
|
||||
brandingAssetReferenceFromAnyUrl,
|
||||
brandingAssetStorageKey,
|
||||
isBrandingAssetReference,
|
||||
mapBrandingAssets,
|
||||
resolveBrandingAsset,
|
||||
toStoredBrandingAsset,
|
||||
} from '@app/api/instance/BrandingAssetReferences';
|
||||
import {
|
||||
getDefaultDateOfBirthCollection,
|
||||
setCachedDateOfBirthCollection,
|
||||
@@ -684,11 +695,26 @@ const StoredInstanceAppPublicSchema = InstanceAppPublicSchema.extend({
|
||||
});
|
||||
|
||||
function parseStoredAppPublicConfig(raw: string | null): InstanceAppPublicConfig {
|
||||
return buildAppPublicConfig(
|
||||
salvageStoredConfig(StoredInstanceAppPublicSchema, readStoredConfigValue(raw, 'app public'), 'app public'),
|
||||
return resolveAppPublicConfig(
|
||||
buildAppPublicConfig(
|
||||
salvageStoredConfig(StoredInstanceAppPublicSchema, readStoredConfigValue(raw, 'app public'), 'app public'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAppPublicConfig(config: InstanceAppPublicConfig): InstanceAppPublicConfig {
|
||||
return {
|
||||
...config,
|
||||
branding: mapBrandingAssets(config.branding, (value) =>
|
||||
resolveBrandingAsset(value ?? null, Config.endpoints.media),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function toStoredBranding<T extends Partial<Record<BrandingAssetField, string | null>>>(branding: T): T {
|
||||
return mapBrandingAssets(branding, (value) => toStoredBrandingAsset(value, Config.endpoints.media));
|
||||
}
|
||||
|
||||
function buildAppPublicConfig(config: z.infer<typeof StoredInstanceAppPublicSchema>): InstanceAppPublicConfig {
|
||||
const defaults = getDefaultAppPublicConfig();
|
||||
const {branding = {}, setup = {}, legal = {}, registration = {}} = config;
|
||||
@@ -1658,8 +1684,8 @@ export class InstanceConfigRepository {
|
||||
StoredInstanceAppPublicSchema,
|
||||
{
|
||||
branding: {
|
||||
...current.branding,
|
||||
...(config.branding ?? {}),
|
||||
...toStoredBranding(current.branding),
|
||||
...toStoredBranding(config.branding ?? {}),
|
||||
premium_product_name: premiumProductName,
|
||||
},
|
||||
setup: {
|
||||
@@ -1677,13 +1703,68 @@ export class InstanceConfigRepository {
|
||||
},
|
||||
'app public',
|
||||
);
|
||||
return {value: JSON.stringify(merged), result: buildAppPublicConfig(merged)};
|
||||
return {value: JSON.stringify(merged), result: resolveAppPublicConfig(buildAppPublicConfig(merged))};
|
||||
});
|
||||
await this.publishRefresh(cache.sourceId);
|
||||
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
|
||||
return next;
|
||||
}
|
||||
|
||||
async normalizeStoredBrandingAssets(storageService: IStorageService): Promise<number> {
|
||||
const cache = this.configCache;
|
||||
await cache.getSnapshot();
|
||||
cache.assertActive();
|
||||
const raw = await this.fetchConfigFromDatabase(APP_PUBLIC_CONFIG_KEY);
|
||||
if (raw === null) return 0;
|
||||
const stored = salvageStoredConfig(
|
||||
StoredInstanceAppPublicSchema,
|
||||
readStoredConfigValue(raw, 'app public'),
|
||||
'app public',
|
||||
);
|
||||
const rewrites = new Map<string, string>();
|
||||
for (const field of BRANDING_ASSET_FIELDS) {
|
||||
const value = stored.branding?.[field];
|
||||
if (typeof value !== 'string' || isBrandingAssetReference(value) || rewrites.has(value)) continue;
|
||||
const reference = toStoredBrandingAsset(value, Config.endpoints.media);
|
||||
if (reference !== value && typeof reference === 'string') {
|
||||
rewrites.set(value, reference);
|
||||
continue;
|
||||
}
|
||||
const candidate = brandingAssetReferenceFromAnyUrl(value);
|
||||
if (candidate === null) continue;
|
||||
const metadata = await storageService.getObjectMetadata(
|
||||
Config.s3.buckets.cdn,
|
||||
brandingAssetStorageKey(candidate),
|
||||
);
|
||||
if (metadata !== null) {
|
||||
rewrites.set(value, candidate);
|
||||
}
|
||||
}
|
||||
if (rewrites.size === 0) return 0;
|
||||
const {result} = await this.compareAndSetStoredValue(cache, APP_PUBLIC_CONFIG_KEY, (current) => {
|
||||
const latest = salvageStoredConfig(
|
||||
StoredInstanceAppPublicSchema,
|
||||
readStoredConfigValue(current, 'app public'),
|
||||
'app public',
|
||||
);
|
||||
if (!latest.branding) return {value: null, result: 0};
|
||||
let changed = 0;
|
||||
const branding = mapBrandingAssets(latest.branding, (value) => {
|
||||
const rewrite = typeof value === 'string' ? rewrites.get(value) : undefined;
|
||||
if (rewrite === undefined) return value;
|
||||
changed++;
|
||||
return rewrite;
|
||||
});
|
||||
if (changed === 0) return {value: null, result: 0};
|
||||
const next = validateStoredConfig(StoredInstanceAppPublicSchema, {...latest, branding}, 'app public');
|
||||
return {value: JSON.stringify(next), result: changed};
|
||||
});
|
||||
if (result > 0) {
|
||||
await this.publishRefresh(cache.sourceId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getInstancePolicyConfig(): Promise<InstancePolicyConfig> {
|
||||
const raw = await this.getConfig(INSTANCE_POLICY_CONFIG_KEY);
|
||||
return parseStoredInstancePolicyConfig(raw);
|
||||
|
||||
@@ -68,6 +68,8 @@ export class UserSettings {
|
||||
readonly syncedPreferences: string;
|
||||
readonly profilePrivacy: ProfilePrivacyLevel;
|
||||
readonly defaultShareVoiceActivity: boolean;
|
||||
readonly privacySetupVersion: number;
|
||||
readonly privacySetupCompletedAt: Date | null;
|
||||
readonly version: number;
|
||||
|
||||
constructor(row: UserSettingsRow) {
|
||||
@@ -115,6 +117,8 @@ export class UserSettings {
|
||||
this.syncedPreferences = normalizeStoredSyncedPreferences(row.synced_preferences);
|
||||
this.profilePrivacy = (row.profile_privacy ?? ProfilePrivacyLevels.ALL_GUILDS) as ProfilePrivacyLevel;
|
||||
this.defaultShareVoiceActivity = row.default_share_voice_activity ?? true;
|
||||
this.privacySetupVersion = row.privacy_setup_version ?? 0;
|
||||
this.privacySetupCompletedAt = row.privacy_setup_completed_at ?? null;
|
||||
this.version = row.version;
|
||||
}
|
||||
|
||||
@@ -171,6 +175,8 @@ export class UserSettings {
|
||||
synced_preferences: this.syncedPreferences === '' ? null : this.syncedPreferences,
|
||||
profile_privacy: this.profilePrivacy,
|
||||
default_share_voice_activity: this.defaultShareVoiceActivity,
|
||||
privacy_setup_version: this.privacySetupVersion,
|
||||
privacy_setup_completed_at: this.privacySetupCompletedAt,
|
||||
version: this.version,
|
||||
};
|
||||
}
|
||||
@@ -216,7 +222,7 @@ export class UserSettings {
|
||||
friend_source_flags: friendSourceFlags,
|
||||
incoming_call_flags: IncomingCallFlags.FRIENDS_ONLY,
|
||||
group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY,
|
||||
default_guilds_restricted: true,
|
||||
default_guilds_restricted: false,
|
||||
bot_default_guilds_restricted: false,
|
||||
restricted_guilds: new Set(),
|
||||
bot_restricted_guilds: new Set(),
|
||||
@@ -244,6 +250,8 @@ export class UserSettings {
|
||||
synced_preferences: null,
|
||||
profile_privacy: ProfilePrivacyLevels.ALL_GUILDS,
|
||||
default_share_voice_activity: true,
|
||||
privacy_setup_version: 0,
|
||||
privacy_setup_completed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,6 +308,14 @@
|
||||
"summary": "Complete handoff",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"anyOf": [{"$ref": "#/components/schemas/HandoffCompleteResponse"}, {"type": "null"}]}
|
||||
}
|
||||
}
|
||||
},
|
||||
"204": {"description": "No Content"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
@@ -340,7 +348,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Complete the handoff process and authenticate on the target device using the handoff code.",
|
||||
"description": "Complete the handoff process and authenticate on the target device using the handoff code. With the deep_link return method, responds with the deep link that hands the one-time grant back to the initiating app. Otherwise responds with no content.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HandoffCompleteRequest"}}}
|
||||
@@ -388,7 +396,11 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking."
|
||||
"description": "Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking. A desktop app that registers a return deep link can be signed in without the user typing the code.",
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HandoffInitiateRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/handoff/{code}": {
|
||||
@@ -445,6 +457,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/handoff/{code}/deny": {
|
||||
"post": {
|
||||
"operationId": "deny_handoff",
|
||||
"summary": "Deny handoff",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"204": {"description": "No Content"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Decline a handoff request after looking it up. The initiating device sees the denied status and the code can no longer be approved.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The handoff code", "type": "string"},
|
||||
"description": "The handoff code"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/handoff/{code}/info": {
|
||||
"get": {
|
||||
"operationId": "get_handoff_info",
|
||||
@@ -24921,6 +24983,12 @@
|
||||
"type": "boolean",
|
||||
"description": "Default share_voice_activity applied to new friend relationships"
|
||||
},
|
||||
"privacy_setup_version": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"description": "Version of the privacy setup the user just reviewed. The server also records privacy_setup_completed_at as the current time."
|
||||
},
|
||||
"synced_preferences": {
|
||||
"description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it.",
|
||||
"anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}]
|
||||
@@ -25074,6 +25142,23 @@
|
||||
"default_share_voice_activity": {
|
||||
"type": "boolean",
|
||||
"description": "Default value of share_voice_activity applied to newly accepted friend relationships. Read-only here; mutated via PUT /users/@me/settings/voice-activity-sharing."
|
||||
},
|
||||
"privacy_setup_version": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Latest privacy setup version the user has reviewed, 0 if never reviewed"
|
||||
},
|
||||
"privacy_setup_completed_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "When the user last completed the privacy setup, or null if never"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -25111,7 +25196,9 @@
|
||||
"staff_dm_access_user_ids",
|
||||
"synced_preferences",
|
||||
"profile_privacy",
|
||||
"default_share_voice_activity"
|
||||
"default_share_voice_activity",
|
||||
"privacy_setup_version",
|
||||
"privacy_setup_completed_at"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -32794,14 +32881,18 @@
|
||||
"HandoffStatusRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"poll_secret": {"description": "The poll secret issued when the handoff was initiated", "type": "string"}
|
||||
"poll_secret": {"description": "The poll secret issued when the handoff was initiated", "type": "string"},
|
||||
"grant": {"description": "The one-time grant delivered to the app through the deep link", "type": "string"}
|
||||
},
|
||||
"required": ["poll_secret"]
|
||||
},
|
||||
"HandoffStatusResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string", "description": "Current status of the handoff (pending, completed, expired)"},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Current status of the handoff (pending, completed, denied, expired)"
|
||||
},
|
||||
"token": {"description": "Authentication token if handoff is complete", "type": ["string", "null"]},
|
||||
"user_id": {
|
||||
"description": "User ID if handoff is complete",
|
||||
@@ -32842,6 +32933,10 @@
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"return_method": {
|
||||
"description": "How the approving browser hands the sign-in back. deep_link opens the initiating app, code relies on the user comparing the code",
|
||||
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
|
||||
}
|
||||
},
|
||||
"required": ["status"],
|
||||
@@ -32854,6 +32949,20 @@
|
||||
},
|
||||
"required": ["poll_secret"]
|
||||
},
|
||||
"HandoffInitiateRequest": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"return_uri": {
|
||||
"description": "Deep link the approving browser opens to return the sign-in to the initiating desktop app",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"HandoffInitiateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -32867,6 +32976,10 @@
|
||||
"poll_secret": {
|
||||
"description": "Secret the initiating device must present to retrieve the token",
|
||||
"type": "string"
|
||||
},
|
||||
"return_method": {
|
||||
"description": "deep_link when the approving browser will hand the sign-in back through the return deep link, code otherwise",
|
||||
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
|
||||
}
|
||||
},
|
||||
"required": ["code", "expires_at"],
|
||||
@@ -32877,10 +32990,25 @@
|
||||
"properties": {
|
||||
"code": {"description": "The handoff code from the initiating session", "type": "string"},
|
||||
"token": {"description": "The authentication token to transfer", "type": "string"},
|
||||
"user_id": {"description": "The user ID associated with the authenticated session", "type": "string"}
|
||||
"user_id": {"description": "The user ID associated with the authenticated session", "type": "string"},
|
||||
"return_method": {
|
||||
"description": "deep_link returns a one-time grant the initiating app must present, code releases the token to the poll secret alone",
|
||||
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
|
||||
}
|
||||
},
|
||||
"required": ["code", "user_id"]
|
||||
},
|
||||
"HandoffCompleteResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"return_url": {
|
||||
"type": "string",
|
||||
"description": "Deep link that returns the sign-in to the initiating app, with its one-time grant"
|
||||
}
|
||||
},
|
||||
"required": ["return_url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ForgotPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -33609,6 +33737,7 @@
|
||||
},
|
||||
"PasswordType": {"type": "string"},
|
||||
"EmailType": {"type": "string"},
|
||||
"DesktopHandoffReturnMethod": {"type": "string", "enum": ["deep_link", "code"]},
|
||||
"AuthSessionLocation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -132,6 +132,10 @@ export const AuthRateLimitConfigs = {
|
||||
bucket: 'auth:handoff:status',
|
||||
config: {limit: 60, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_HANDOFF_DENY: {
|
||||
bucket: 'auth:handoff:deny',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_HANDOFF_CANCEL: {
|
||||
bucket: 'auth:handoff:cancel',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
|
||||
@@ -353,6 +353,8 @@ export function mapUserSettingsToResponse(params: {settings: UserSettings}): Use
|
||||
synced_preferences: settings.syncedPreferences,
|
||||
profile_privacy: settings.profilePrivacy,
|
||||
default_share_voice_activity: settings.defaultShareVoiceActivity,
|
||||
privacy_setup_version: settings.privacySetupVersion,
|
||||
privacy_setup_completed_at: settings.privacySetupCompletedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,10 @@ export class UserAccountSettingsService {
|
||||
if (data.bot_default_guilds_restricted !== undefined) {
|
||||
updatedRowData.bot_default_guilds_restricted = data.bot_default_guilds_restricted;
|
||||
}
|
||||
if (data.privacy_setup_version !== undefined) {
|
||||
updatedRowData.privacy_setup_version = data.privacy_setup_version;
|
||||
updatedRowData.privacy_setup_completed_at = new Date();
|
||||
}
|
||||
if (data.inline_attachment_media !== undefined) {
|
||||
updatedRowData.inline_attachment_media = data.inline_attachment_media;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {fetchUserSettings, updateUserSettings} from '@app/api/user/tests/UserTestUtils';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('User settings privacy setup', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
test('new accounts allow community DMs and have not reviewed the privacy setup', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {json} = await fetchUserSettings(harness, account.token);
|
||||
expect(json.default_guilds_restricted).toBe(false);
|
||||
expect(json.privacy_setup_version).toBe(0);
|
||||
expect(json.privacy_setup_completed_at).toBeNull();
|
||||
});
|
||||
test('completing the privacy setup stores the version, the time and the chosen setting in one update', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const before = Date.now();
|
||||
const {json} = await updateUserSettings(harness, account.token, {
|
||||
default_guilds_restricted: true,
|
||||
privacy_setup_version: 1,
|
||||
});
|
||||
expect(json.default_guilds_restricted).toBe(true);
|
||||
expect(json.privacy_setup_version).toBe(1);
|
||||
expect(Date.parse(json.privacy_setup_completed_at ?? '')).toBeGreaterThanOrEqual(before - 1000);
|
||||
const {json: reread} = await fetchUserSettings(harness, account.token);
|
||||
expect(reread.privacy_setup_version).toBe(1);
|
||||
expect(reread.privacy_setup_completed_at).toBe(json.privacy_setup_completed_at);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch('/users/@me/settings')
|
||||
.body({privacy_setup_version: 0})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -294,6 +294,14 @@ export async function startWorkerMain(): Promise<void> {
|
||||
await startContentBlocklistCaches({kvClient: dependencies.kvClient, storageService: dependencies.storageService});
|
||||
Logger.info('Content blocklist caches initialised for worker backend');
|
||||
await queueBlocklistFeedStartupJobs(dependencies.kvClient, workerService, Config.blocklistFeeds.enabled);
|
||||
try {
|
||||
const normalized = await getInstanceConfigRepository().normalizeStoredBrandingAssets(dependencies.storageService);
|
||||
if (normalized > 0) {
|
||||
Logger.info({normalized}, 'Normalised stored instance branding assets to references');
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn({err: error}, 'Failed to normalise stored instance branding assets');
|
||||
}
|
||||
setActivityProcessChannel('worker');
|
||||
await startActivityEvents({
|
||||
publisher: jetStreamActivityPublisher(jsConnectionManager.getJetStreamClient()),
|
||||
|
||||
@@ -569,6 +569,8 @@ export function buildUserDataJson(params: UserDataJsonParams) {
|
||||
staff_dm_access_user_ids: Array.from(userSettings.staffDmAccessUserIds).map((id) => id.toString()),
|
||||
synced_preferences: syncedPreferencesToJson(decodeSyncedPreferencesLenient(userSettings.syncedPreferences)),
|
||||
profile_privacy: userSettings.profilePrivacy,
|
||||
privacy_setup_version: userSettings.privacySetupVersion,
|
||||
privacy_setup_completed_at: userSettings.privacySetupCompletedAt?.toISOString() ?? null,
|
||||
}
|
||||
: null,
|
||||
guild_memberships: guildMemberships
|
||||
|
||||
@@ -671,6 +671,7 @@ export default () => {
|
||||
wasmCratesDir: path.join(ROOT_DIR, 'rust'),
|
||||
}),
|
||||
new DefinePlugin({
|
||||
define: 'undefined',
|
||||
__FLUXER_PRECACHE_MANIFEST__: JSON.stringify([]),
|
||||
__FLUXER_SW_VERSION__: JSON.stringify(publicValues.PUBLIC_BUILD_VERSION || 'dev'),
|
||||
'process.env.NODE_ENV': JSON.stringify(mode),
|
||||
|
||||
@@ -26,6 +26,7 @@ import GatewayConnection from '@app/features/gateway/transport/GatewayConnection
|
||||
import GatewaySessions from '@app/features/gateway/transport/GatewaySessionPool';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import MemberSidebar from '@app/features/member/state/MemberSidebar';
|
||||
import {attachExternalLinkInterceptor} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import {startDeepLinkHandling} from '@app/features/navigation/utils/DeepLinkUtils';
|
||||
import {Outlet, RouterProvider} from '@app/features/platform/components/router/RouterReact';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
@@ -50,7 +51,7 @@ import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import Modal from '@app/features/ui/state/Modal';
|
||||
import Popout from '@app/features/ui/state/Popout';
|
||||
import {getDesktopWindowBehaviorSettings} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
|
||||
import {attachExternalLinkInterceptor, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import {UNFOCUSED_FULLY_INTERACTIVE_CLASS} from '@app/features/ui/utils/WindowFocusInteractionGuard';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import {IncomingCallManager} from '@app/features/voice/components/IncomingCallManager';
|
||||
@@ -93,9 +94,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
() => ({variant: layoutVariant, setVariant: setLayoutVariant}),
|
||||
[layoutVariant],
|
||||
);
|
||||
const popouts = Popout.getPopouts();
|
||||
const topPopout = popouts.length ? popouts[popouts.length - 1] : null;
|
||||
const topPopoutRequiresBackdrop = Boolean(topPopout && !topPopout.disableBackdrop);
|
||||
const topPopoutRequiresBackdrop = Popout.requiresBackdrop();
|
||||
const hasBlockingModal = Modal.hasModalOpen();
|
||||
const room = MediaEngine.room;
|
||||
const ringsContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {getDocumentFaviconUrl} from '@app/features/app/state/RuntimeDocumentBranding';
|
||||
import DesktopBackgroundGateway from '@app/features/gateway/transport/DesktopBackgroundGateway';
|
||||
import GuildReadState from '@app/features/guild/state/GuildReadState';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -18,14 +19,40 @@ const logger = new Logger('AppBadge');
|
||||
const UNREAD_INDICATOR = -1;
|
||||
|
||||
let favico: Favico | null = null;
|
||||
let favicoLink: HTMLLinkElement | null = null;
|
||||
let favicoLinkHref: string | null = null;
|
||||
let favicoSource: string | null = null;
|
||||
|
||||
const findIconLink = (): HTMLLinkElement | null => {
|
||||
const links = document.head.querySelectorAll<HTMLLinkElement>('link');
|
||||
for (let index = links.length - 1; index >= 0; index--) {
|
||||
if (/(^|\s)icon(\s|$)/i.test(links[index].getAttribute('rel') ?? '')) {
|
||||
return links[index];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const releaseFavico = (): void => {
|
||||
if (favicoLink !== null && favicoLinkHref !== null && favicoLink.getAttribute('href')?.startsWith('data:')) {
|
||||
favicoLink.setAttribute('href', favicoLinkHref);
|
||||
}
|
||||
favico = null;
|
||||
favicoLink = null;
|
||||
favicoLinkHref = null;
|
||||
};
|
||||
|
||||
const initFavico = (): Favico | null => {
|
||||
if (favico) return favico;
|
||||
try {
|
||||
favico = new Favico({animation: 'none'});
|
||||
const link = findIconLink();
|
||||
favicoLink = link;
|
||||
favicoLinkHref = link?.getAttribute('href') ?? null;
|
||||
favico = new Favico(link === null ? {animation: 'none'} : {animation: 'none', element: link});
|
||||
return favico;
|
||||
} catch (e) {
|
||||
logger.warn('Failed to initialize Favico', e);
|
||||
releaseFavico();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -39,7 +66,11 @@ const setElectronBadge = (badge: number): void => {
|
||||
logger.warn('Failed to set Electron badge', e);
|
||||
}
|
||||
};
|
||||
const setFaviconBadge = (badge: number): void => {
|
||||
const setFaviconBadge = (badge: number, source: string | null): void => {
|
||||
if (source !== favicoSource) {
|
||||
releaseFavico();
|
||||
favicoSource = source;
|
||||
}
|
||||
if (badge === 0 && favico === null) return;
|
||||
const fav = initFavico();
|
||||
if (!fav) return;
|
||||
@@ -69,9 +100,9 @@ const setPwaBadge = (badge: number): void => {
|
||||
logger.warn('Failed to set PWA badge', e);
|
||||
}
|
||||
};
|
||||
const setBadge = (badge: number): void => {
|
||||
const setBadge = (badge: number, faviconSource: string | null): void => {
|
||||
setElectronBadge(badge);
|
||||
setFaviconBadge(badge);
|
||||
setFaviconBadge(badge, faviconSource);
|
||||
setPwaBadge(badge);
|
||||
};
|
||||
export const AppBadge: React.FC = observer(() => {
|
||||
@@ -89,15 +120,16 @@ export const AppBadge: React.FC = observer(() => {
|
||||
} else if (hasUnread && unreadMessageBadgeEnabled) {
|
||||
badge = UNREAD_INDICATOR;
|
||||
}
|
||||
const faviconSource = getDocumentFaviconUrl(RuntimeConfig.getSnapshotOrNull()?.appPublic ?? null);
|
||||
useEffect(() => {
|
||||
setBadge(badge);
|
||||
}, [badge]);
|
||||
setBadge(badge, faviconSource);
|
||||
}, [badge, faviconSource]);
|
||||
useEffect(() => {
|
||||
updateDocumentTitleBadge(totalCount, hasUnread && unreadMessageBadgeEnabled);
|
||||
}, [totalCount, hasUnread, unreadMessageBadgeEnabled]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setBadge(0);
|
||||
setBadge(0, favicoSource);
|
||||
updateDocumentTitleBadge(0, false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
+8
-4
@@ -139,8 +139,10 @@ export const FeatureComparisonTable = observer(() => {
|
||||
<ComparisonCheckRow
|
||||
key={perk.id}
|
||||
feature={label}
|
||||
restrictedHas={perk.restrictedValue}
|
||||
stockHas={perk.stockValue}
|
||||
restrictedHas={
|
||||
perk.limitKey ? Limits.hasRestrictedFeature(perk.limitKey, perk.restrictedValue) : perk.restrictedValue
|
||||
}
|
||||
stockHas={perk.limitKey ? Limits.hasStockFeature(perk.limitKey, perk.stockValue) : perk.stockValue}
|
||||
data-flx="app.feature-comparison-table.render-perk-row.comparison-check-row"
|
||||
/>
|
||||
);
|
||||
@@ -157,9 +159,11 @@ export const FeatureComparisonTable = observer(() => {
|
||||
);
|
||||
}
|
||||
if (isTextTierPerk(perk)) {
|
||||
const restrictedLabel =
|
||||
const lowLabel =
|
||||
perkLabels[perk.restrictedValueI18nKey as keyof typeof perkLabels] || perk.restrictedValueI18nKey;
|
||||
const stockLabel = perkLabels[perk.stockValueI18nKey as keyof typeof perkLabels] || perk.stockValueI18nKey;
|
||||
const highLabel = perkLabels[perk.stockValueI18nKey as keyof typeof perkLabels] || perk.stockValueI18nKey;
|
||||
const restrictedLabel = perk.limitKey && Limits.hasRestrictedFeature(perk.limitKey, false) ? highLabel : lowLabel;
|
||||
const stockLabel = !perk.limitKey || Limits.hasStockFeature(perk.limitKey, true) ? highLabel : lowLabel;
|
||||
return (
|
||||
<ComparisonRow
|
||||
key={perk.id}
|
||||
|
||||
@@ -31,6 +31,25 @@
|
||||
line-height: 0.875rem;
|
||||
}
|
||||
|
||||
.instanceHeadingMark {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 0.1875rem;
|
||||
}
|
||||
|
||||
.instanceHeadingMark svg,
|
||||
.instanceHeadingMark img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.instanceHeadingLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from '@app/features/auth/components/accounts/AccountIdentity';
|
||||
import {getAccountMentionCount} from '@app/features/auth/components/accounts/account_switcher_modal/AccountMentionCounts';
|
||||
import {SESSION_EXPIRED_DESCRIPTOR} from '@app/features/auth/components/accounts/account_switcher_modal/AccountPickerShared';
|
||||
import {InstanceBrandMark} from '@app/features/auth/components/InstanceBrandMark';
|
||||
import {resolveInstanceLabel} from '@app/features/auth/flow/instance_selector/InstanceDirectoryStorage';
|
||||
import {resolveInstanceBrandIconUrl, resolveInstanceProductName} from '@app/features/auth/InstanceBranding';
|
||||
import {getAccountKey} from '@app/features/auth/state/AccountStorageKey';
|
||||
import {ACTIVE_ACCOUNT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import type {Account} from '@app/features/platform/state/AuthSession';
|
||||
@@ -18,6 +21,7 @@ import {MentionBadge} from '@app/features/ui/components/MentionBadge';
|
||||
import {MockAvatar} from '@app/features/ui/components/MockAvatar';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import {isOfficialInstanceHost} from '@fluxer/instance_bootstrap/src/OfficialInstance';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CheckIcon, GearIcon} from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
@@ -30,6 +34,8 @@ const UNKNOWN_INSTANCE_GROUP_KEY = 'unknown';
|
||||
interface AccountInstanceGroup {
|
||||
readonly groupKey: string;
|
||||
readonly instanceLabel: string | null;
|
||||
readonly isOfficial: boolean;
|
||||
readonly iconUrl: string | null;
|
||||
readonly accounts: Array<Account>;
|
||||
}
|
||||
|
||||
@@ -46,7 +52,13 @@ function createAccountInstanceGroups(accounts: ReadonlyArray<Account>): Array<Ac
|
||||
const instanceKey = resolveAccountInstanceKey(account);
|
||||
if (instanceKey == null) {
|
||||
if (unknownGroup == null) {
|
||||
unknownGroup = {groupKey: UNKNOWN_INSTANCE_GROUP_KEY, instanceLabel: null, accounts: []};
|
||||
unknownGroup = {
|
||||
groupKey: UNKNOWN_INSTANCE_GROUP_KEY,
|
||||
instanceLabel: null,
|
||||
isOfficial: false,
|
||||
iconUrl: null,
|
||||
accounts: [],
|
||||
};
|
||||
}
|
||||
unknownGroup.accounts.push(account);
|
||||
continue;
|
||||
@@ -54,7 +66,16 @@ function createAccountInstanceGroups(accounts: ReadonlyArray<Account>): Array<Ac
|
||||
const groupKey = `instance:${instanceKey}`;
|
||||
let group = groupsByKey.get(groupKey);
|
||||
if (group == null) {
|
||||
group = {groupKey, instanceLabel: resolveAccountInstanceLabel(account), accounts: []};
|
||||
const domain = resolveAccountInstanceLabel(account);
|
||||
const isOfficial = isOfficialInstanceHost(instanceKey);
|
||||
const productName = isOfficial ? null : resolveInstanceProductName(account.instance);
|
||||
group = {
|
||||
groupKey,
|
||||
instanceLabel: domain == null ? null : resolveInstanceLabel(productName, domain),
|
||||
isOfficial,
|
||||
iconUrl: isOfficial ? null : resolveInstanceBrandIconUrl(account.instance),
|
||||
accounts: [],
|
||||
};
|
||||
groupsByKey.set(groupKey, group);
|
||||
groups.push(group);
|
||||
}
|
||||
@@ -202,6 +223,17 @@ export const UserAreaAccountSwitchMenu = observer(
|
||||
className={flxElementClassName(styles.instanceHeading)}
|
||||
data-flx="app.floating.user-area-account-switch-menu.instance-heading"
|
||||
>
|
||||
<span
|
||||
className={styles.instanceHeadingMark}
|
||||
data-flx="app.floating.user-area-account-switch-menu.instance-heading-mark"
|
||||
>
|
||||
<InstanceBrandMark
|
||||
isOfficial={group.isOfficial}
|
||||
iconUrl={group.iconUrl}
|
||||
size={12}
|
||||
data-flx="app.floating.user-area-account-switch-menu.instance-brand-mark"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={styles.instanceHeadingLabel}
|
||||
data-flx="app.floating.user-area-account-switch-menu.instance-heading-label"
|
||||
|
||||
@@ -16,6 +16,7 @@ import {GuildHeaderBottomSheet} from '@app/features/guild/components/bottomsheet
|
||||
import {GuildBadge} from '@app/features/guild/components/GuildBadge';
|
||||
import {GuildHeaderPopout} from '@app/features/guild/components/popouts/GuildHeaderPopout';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery';
|
||||
import {GuildContextMenu} from '@app/features/ui/action_menu/GuildContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
@@ -74,6 +75,7 @@ export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: Gui
|
||||
[guild],
|
||||
);
|
||||
const guildNameRef = useRef<HTMLSpanElement | null>(null);
|
||||
const bannerImageURL = useRecoveringBackgroundImageURL(banner.imageUrl ?? '');
|
||||
const badgeVisible = resolveGuildBadgeVisible(guild.features);
|
||||
const bannerPlacement = resolveSkeletonBannerPlacement(banner.hasBanner, banner.isDetached);
|
||||
useSkeletonLayoutReport(() => {
|
||||
@@ -98,7 +100,7 @@ export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: Gui
|
||||
style={{
|
||||
height: banner.bannerHeight,
|
||||
y: banner.imageShift,
|
||||
backgroundImage: `url(${banner.imageUrl})`,
|
||||
backgroundImage: bannerImageURL == null ? undefined : `url(${bannerImageURL})`,
|
||||
}}
|
||||
data-flx="app.guild-header.banner-image"
|
||||
/>
|
||||
|
||||
@@ -160,14 +160,9 @@
|
||||
align-items: flex-end;
|
||||
padding: 0 var(--footer-box-inset-inline) var(--user-area-box-inset-block-end);
|
||||
background-color: var(--background-secondary);
|
||||
pointer-events: none;
|
||||
z-index: var(--z-index-elevated-1);
|
||||
}
|
||||
|
||||
.userAreaWrapper > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.guildListItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -206,6 +201,7 @@
|
||||
color 70ms ease-out;
|
||||
container-type: size;
|
||||
--initials-font-size: clamp(0.85rem, 45cqi, 1.35rem);
|
||||
--initials-fit-inline-size: 92cqi;
|
||||
--initials-letter-spacing: 0px;
|
||||
--initials-scale: 1;
|
||||
}
|
||||
@@ -259,7 +255,10 @@
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
font-size: var(--initials-font-size);
|
||||
font-size: min(
|
||||
var(--initials-font-size),
|
||||
calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale)))
|
||||
);
|
||||
letter-spacing: var(--initials-letter-spacing);
|
||||
transform: scale(var(--initials-scale));
|
||||
transform-origin: center;
|
||||
@@ -271,12 +270,6 @@
|
||||
--initials-scale: 0.92;
|
||||
}
|
||||
|
||||
.guildIcon[data-initials-length='long'] .guildIconInitials {
|
||||
--initials-font-size: clamp(0.85rem, 32cqi, 0.87rem);
|
||||
--initials-letter-spacing: 0px;
|
||||
--initials-scale: 0.78;
|
||||
}
|
||||
|
||||
.guildIndicator {
|
||||
position: absolute;
|
||||
left: var(--guild-list-indicator-inset);
|
||||
|
||||
@@ -1278,11 +1278,11 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
|
||||
[NagbarType.LEGACY_PRICE_OPT_IN]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.GIFT_INVENTORY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.DESKTOP_DOWNLOAD]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.DESKTOP_UPDATE_READY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.GUILD_MEMBERSHIP_CTA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.VISIONARY_MFA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.VOICE_SESSION_RESTORE]: {tone: SkeletonNagbarTone.VOICE, hasActions: true},
|
||||
[NagbarType.TERMS_ACCEPTANCE]: {tone: SkeletonNagbarTone.LEGAL, hasActions: true},
|
||||
[NagbarType.PRIVACY_SETUP]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.SOFTWARE_ENCODER]: {tone: SkeletonNagbarTone.ENCODER, hasActions: true},
|
||||
[NagbarType.STREAMER_MODE]: {tone: SkeletonNagbarTone.STREAMER, hasActions: true},
|
||||
[NagbarType.DOMAIN_MOVED]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {CHANNEL_TEXTAREA_SELECTOR} from '@app/features/app/keybindings/utils/EditableElement';
|
||||
import {
|
||||
CHANNEL_TEXTAREA_SELECTOR,
|
||||
isChannelTextareaElement,
|
||||
isEditableElement,
|
||||
} from '@app/features/app/keybindings/utils/EditableElement';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
|
||||
@@ -29,6 +33,7 @@ const canRedirectTabToComposer = (composer: HTMLTextAreaElement | null): compose
|
||||
if (composer == null || composer.disabled || composer.getAttribute('aria-disabled') === 'true') return false;
|
||||
if (isElementInert(composer)) return false;
|
||||
const active = document.activeElement;
|
||||
if (isEditableElement(active) && !isChannelTextareaElement(active)) return false;
|
||||
return !(active instanceof Element && active.closest(FOCUS_TRAPPING_OVERLAY_SELECTOR) != null);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import DomainMovedNotice from '@app/features/app/domain_migration/DomainMovedNot
|
||||
import {isClientReconnecting} from '@app/features/app/state/ClientReadiness';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Updater from '@app/features/app/state/Updater';
|
||||
import Accounts from '@app/features/auth/state/Accounts';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
@@ -31,7 +30,9 @@ import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
|
||||
import Nagbar from '@app/features/ui/state/Nagbar';
|
||||
import {hasUnavailableElectronNativeContext, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import {isStandalonePwa} from '@app/features/ui/utils/PwaUtils';
|
||||
import {PRIVACY_SETUP_VERSION} from '@app/features/user/constants/PrivacySetupConstants';
|
||||
import StatusPage from '@app/features/user/state/StatusPage';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import MediaEngine, {useVoiceEngineV2Model} from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {selectVoiceEngineV2AppConnectionWithFallback} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
||||
@@ -259,7 +260,6 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
})();
|
||||
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
|
||||
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
|
||||
const canShowDesktopUpdateReady = Updater.shouldShowUpdateReadyNagbar;
|
||||
const canShowDomainMoved = nagbarState.forceHideDomainMoved
|
||||
? false
|
||||
: nagbarState.forceDomainMoved
|
||||
@@ -287,6 +287,13 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
(!user.privacyAgreedAt || user.privacyAgreedAt.toISOString() < PRIVACY_POLICY_LAST_UPDATED);
|
||||
return termsOutdated || privacyOutdated;
|
||||
})();
|
||||
const needsPrivacySetup = (() => {
|
||||
if (nagbarState.forceHidePrivacySetup) return false;
|
||||
if (nagbarState.forcePrivacySetup) return true;
|
||||
if (!user || !UserSettings.isHydrated()) return false;
|
||||
const privacySetupVersion = UserSettings.getPrivacySetupVersion();
|
||||
return privacySetupVersion !== null && privacySetupVersion < PRIVACY_SETUP_VERSION;
|
||||
})();
|
||||
return {
|
||||
canShowBuildEnvironment,
|
||||
canShowConnection,
|
||||
@@ -327,9 +334,9 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
canShowVisionaryMfa,
|
||||
canShowVoiceSessionRestore,
|
||||
needsTermsAcceptance,
|
||||
needsPrivacySetup,
|
||||
canShowSoftwareEncoder,
|
||||
canShowStreamerMode,
|
||||
canShowDesktopUpdateReady,
|
||||
canShowDomainMoved,
|
||||
};
|
||||
};
|
||||
@@ -360,6 +367,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.needsTermsAcceptance,
|
||||
dismissible: false,
|
||||
},
|
||||
{
|
||||
type: NagbarType.PRIVACY_SETUP,
|
||||
priority: -2.75,
|
||||
visible: conditions.needsPrivacySetup,
|
||||
dismissible: false,
|
||||
},
|
||||
{
|
||||
type: NagbarType.SCHEDULED_MAINTENANCE,
|
||||
priority: -1,
|
||||
@@ -462,12 +475,6 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.canShowStreamerMode,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.DESKTOP_UPDATE_READY,
|
||||
priority: -1.5,
|
||||
visible: conditions.canShowDesktopUpdateReady,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.DOMAIN_MOVED,
|
||||
priority: 3,
|
||||
|
||||
@@ -18,11 +18,11 @@ export const NagbarType = {
|
||||
LEGACY_PRICE_OPT_IN: 'legacy-price-opt-in',
|
||||
GIFT_INVENTORY: 'gift-inventory',
|
||||
DESKTOP_DOWNLOAD: 'desktop-download',
|
||||
DESKTOP_UPDATE_READY: 'desktop-update-ready',
|
||||
GUILD_MEMBERSHIP_CTA: 'guild-membership-cta',
|
||||
VISIONARY_MFA: 'visionary-mfa',
|
||||
VOICE_SESSION_RESTORE: 'voice-session-restore',
|
||||
TERMS_ACCEPTANCE: 'terms-acceptance',
|
||||
PRIVACY_SETUP: 'privacy-setup',
|
||||
SOFTWARE_ENCODER: 'software-encoder',
|
||||
STREAMER_MODE: 'streamer-mode',
|
||||
DOMAIN_MOVED: 'domain-moved',
|
||||
@@ -57,11 +57,11 @@ export interface NagbarConditions {
|
||||
canShowLegacyPriceOptIn: boolean;
|
||||
canShowGiftInventory: boolean;
|
||||
canShowDesktopDownload: boolean;
|
||||
canShowDesktopUpdateReady: boolean;
|
||||
canShowGuildMembershipCta: boolean;
|
||||
canShowVisionaryMfa: boolean;
|
||||
canShowVoiceSessionRestore: boolean;
|
||||
needsTermsAcceptance: boolean;
|
||||
needsPrivacySetup: boolean;
|
||||
canShowSoftwareEncoder: boolean;
|
||||
canShowStreamerMode: boolean;
|
||||
canShowDomainMoved: boolean;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {ConnectionNagbar} from '@app/features/app/components/layout/app_layout/n
|
||||
import {CorruptedInstallationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/CorruptedInstallationNagbar';
|
||||
import {DesktopDownloadNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopDownloadNagbar';
|
||||
import {DesktopNotificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopNotificationNagbar';
|
||||
import {DesktopUpdateReadyNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopUpdateReadyNagbar';
|
||||
import {DomainMovedNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DomainMovedNagbar';
|
||||
import {EmailVerificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/EmailVerificationNagbar';
|
||||
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
|
||||
@@ -18,6 +17,7 @@ import {PremiumExpiredNagbar} from '@app/features/app/components/layout/app_layo
|
||||
import {PremiumGracePeriodNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumGracePeriodNagbar';
|
||||
import {PremiumOnboardingNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumOnboardingNagbar';
|
||||
import {PriceAnnouncementNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PriceAnnouncementNagbar';
|
||||
import {PrivacySetupNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PrivacySetupNagbar';
|
||||
import {ScheduledMaintenanceNagbar} from '@app/features/app/components/layout/app_layout/nagbars/ScheduledMaintenanceNagbar';
|
||||
import {StreamerModeNagbar} from '@app/features/app/components/layout/app_layout/nagbars/StreamerModeNagbar';
|
||||
import {TermsAcceptanceNagbar} from '@app/features/app/components/layout/app_layout/nagbars/TermsAcceptanceNagbar';
|
||||
@@ -81,6 +81,14 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
data-flx="app.app-layout.nagbar-container.terms-acceptance-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.PRIVACY_SETUP:
|
||||
return (
|
||||
<PrivacySetupNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.privacy-setup-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.SCHEDULED_MAINTENANCE:
|
||||
return (
|
||||
<ScheduledMaintenanceNagbar
|
||||
@@ -183,14 +191,6 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
data-flx="app.app-layout.nagbar-container.desktop-download-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.DESKTOP_UPDATE_READY:
|
||||
return (
|
||||
<DesktopUpdateReadyNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.desktop-update-ready-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.GUILD_MEMBERSHIP_CTA:
|
||||
return (
|
||||
<GuildMembershipCtaNagbar
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import Updater from '@app/features/app/state/Updater';
|
||||
import {
|
||||
DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR,
|
||||
RESTART_FLUXER_DESCRIPTOR,
|
||||
THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR,
|
||||
} from '@app/features/updater/commands/UpdaterModalCommands';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
|
||||
export const DesktopUpdateReadyNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
||||
const {i18n} = useLingui();
|
||||
const version = Updater.updateInfo.native.version;
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={Updater.dismissUpdateReadyNagbar}
|
||||
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={Updater.dismissUpdateReadyNagbar}
|
||||
message={
|
||||
version
|
||||
? i18n._(DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR, {version, productName: PRODUCT_NAME})
|
||||
: i18n._(THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
}
|
||||
actions={
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={() => void Updater.applyUpdate()}
|
||||
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.restart-button"
|
||||
>
|
||||
{i18n._(RESTART_FLUXER_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
</NagbarButton>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {PrivacySetupModal} from '@app/features/user/components/modals/PrivacySetupModal';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const PRIVACY_SETUP_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'Choose who can send you direct messages.',
|
||||
comment: 'Nagbar body asking the user to review who can send them direct messages.',
|
||||
});
|
||||
const REVIEW_PRIVACY_SETTINGS_DESCRIPTOR = msg({
|
||||
message: 'Review privacy settings',
|
||||
comment: 'Button label on the privacy setup nagbar. Opens the privacy setup modal.',
|
||||
});
|
||||
export const PrivacySetupNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
||||
const {i18n} = useLingui();
|
||||
const handleOpen = useCallback(() => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<PrivacySetupModal data-flx="app.app-layout.nagbars.privacy-setup-nagbar.handle-open.privacy-setup-modal" />
|
||||
)),
|
||||
);
|
||||
}, []);
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
data-flx="app.app-layout.nagbars.privacy-setup-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
message={i18n._(PRIVACY_SETUP_MESSAGE_DESCRIPTOR)}
|
||||
actions={
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={handleOpen}
|
||||
data-flx="app.app-layout.nagbars.privacy-setup-nagbar.nagbar-button.open"
|
||||
>
|
||||
{i18n._(REVIEW_PRIVACY_SETTINGS_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.privacy-setup-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import GuildReadState from '@app/features/guild/state/GuildReadState';
|
||||
import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {MENTION_COUNT_ARIA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import Theme from '@app/features/theme/state/Theme';
|
||||
import {Edge} from '@app/features/ui/AxisOrientation';
|
||||
@@ -766,8 +767,8 @@ interface MiniGuildIconProps {
|
||||
}
|
||||
|
||||
const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => {
|
||||
const iconURL = AvatarSourceUtils.getGuildIconURL(guild, false);
|
||||
if (iconURL !== '') {
|
||||
const iconURL = useRecoveringBackgroundImageURL(AvatarSourceUtils.getGuildIconURL(guild, false));
|
||||
if (iconURL != null) {
|
||||
return (
|
||||
<flx-app-mini-guild-icon
|
||||
className={flxElementClassName(styles.miniGuildIcon)}
|
||||
|
||||
+8
-6
@@ -14,6 +14,7 @@ import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {
|
||||
type GuildInitialsLength,
|
||||
getGuildIconDisplayInitials,
|
||||
getGuildInitialsFitStyle,
|
||||
getInitialsFromName,
|
||||
getInitialsLength,
|
||||
truncateInitials,
|
||||
@@ -57,11 +58,11 @@ function resolveGuildListItemMotion(useReducedMotion: boolean): GuildListItemMot
|
||||
return STANDARD_GUILD_LIST_ITEM_MOTION;
|
||||
}
|
||||
|
||||
function resolveInitialsLength(rawInitials: string): GuildInitialsLength | null {
|
||||
if (rawInitials.length === 0) {
|
||||
function resolveInitialsLength(displayInitials: string): GuildInitialsLength | null {
|
||||
if (displayInitials.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return getInitialsLength(rawInitials);
|
||||
return getInitialsLength(displayInitials);
|
||||
}
|
||||
|
||||
function resolveDraggingCursor(dragAndDrop: GuildListItemDragAndDrop | null): 'grabbing' | null {
|
||||
@@ -303,8 +304,8 @@ export const GuildListItemPresentation = forwardRef<
|
||||
forwardedRef,
|
||||
) {
|
||||
const rootRef = useMergeRefs([surfaceRef, forwardedRef]);
|
||||
const rawInitials = getInitialsFromName(guild.name);
|
||||
const initialsLength = resolveInitialsLength(rawInitials);
|
||||
const displayInitials = getGuildIconDisplayInitials(getInitialsFromName(guild.name));
|
||||
const initialsLength = resolveInitialsLength(displayInitials);
|
||||
const dropIndicator = resolveDropIndicator(dragAndDrop);
|
||||
const motion = resolveGuildListItemMotion(Accessibility.useReducedMotion);
|
||||
const draggingCursor = resolveDraggingCursor(dragAndDrop);
|
||||
@@ -378,9 +379,10 @@ export const GuildListItemPresentation = forwardRef<
|
||||
{!hasPaintedIcon && (
|
||||
<span
|
||||
className={styles.guildIconInitials}
|
||||
style={getGuildInitialsFitStyle(displayInitials)}
|
||||
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon-initials"
|
||||
>
|
||||
{getGuildIconDisplayInitials(rawInitials)}
|
||||
{displayInitials}
|
||||
</span>
|
||||
)}
|
||||
</GuildListItemIcon>
|
||||
|
||||
+5
-4
@@ -6,6 +6,7 @@ import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHo
|
||||
import {useHover} from '@app/features/app/hooks/useHover';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery';
|
||||
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
|
||||
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
||||
import {GuildContextMenu} from '@app/features/ui/action_menu/GuildContextMenu';
|
||||
@@ -48,8 +49,8 @@ function resolveDisplayedGuildIconURL(query: DisplayedGuildIconURLQuery): string
|
||||
return query.iconURL;
|
||||
}
|
||||
|
||||
function resolveGuildIconBackgroundImage(iconURL: string, displayedIconURL: string): string | null {
|
||||
if (iconURL.length === 0) {
|
||||
function resolveGuildIconBackgroundImage(paintableIconURL: string | null, displayedIconURL: string): string | null {
|
||||
if (paintableIconURL == null) {
|
||||
return null;
|
||||
}
|
||||
return `url(${displayedIconURL})`;
|
||||
@@ -75,7 +76,7 @@ export function useGuildListItemInteraction({
|
||||
const isAnimatableIcon = hoverIconURL !== iconURL;
|
||||
const [loadedAnimatedURL, setLoadedAnimatedURL] = useState<string | null>(null);
|
||||
const [bottomSheetOpen, setBottomSheetOpen] = useState(false);
|
||||
useEffect(() => ImageCacheUtils.pinImage(iconURL), [iconURL]);
|
||||
const paintableIconURL = useRecoveringBackgroundImageURL(iconURL);
|
||||
useEffect(() => {
|
||||
if (!isAnimatableIcon) return;
|
||||
if (!isHovering && !contextMenuOpen) return;
|
||||
@@ -121,7 +122,7 @@ export function useGuildListItemInteraction({
|
||||
const handleCloseBottomSheet = useCallback(() => setBottomSheetOpen(false), []);
|
||||
const shouldPlayAnimated = isAnimatableIcon && (isHovering || contextMenuOpen) && loadedAnimatedURL === hoverIconURL;
|
||||
const displayedIconURL = resolveDisplayedGuildIconURL({hoverIconURL, iconURL, shouldPlayAnimated});
|
||||
const backgroundImage = resolveGuildIconBackgroundImage(iconURL, displayedIconURL);
|
||||
const backgroundImage = resolveGuildIconBackgroundImage(paintableIconURL, displayedIconURL);
|
||||
return {
|
||||
hoverRef,
|
||||
isHovering,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const Endpoints = {
|
||||
AUTH_HANDOFF_INFO: (code: string) => `/auth/handoff/${code}/info`,
|
||||
AUTH_HANDOFF_STATUS: (code: string) => `/auth/handoff/${code}/status`,
|
||||
AUTH_HANDOFF_CANCEL: (code: string) => `/auth/handoff/${code}`,
|
||||
AUTH_HANDOFF_DENY: (code: string) => `/auth/handoff/${code}/deny`,
|
||||
AUTH_PASSKEY_BRIDGE: '/auth/passkey-bridge',
|
||||
AUTH_PASSKEY_BRIDGE_OPTIONS: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/options`,
|
||||
AUTH_PASSKEY_BRIDGE_COMPLETE: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/complete`,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/ProductConstants';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
|
||||
export function getActiveInstanceProductName(): string {
|
||||
return RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.product_name?.trim() || PRODUCT_NAME;
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DEFAULT_APP_SHELL_BRANDING} from '@app/features/app/state/AppShellBranding';
|
||||
import {setDocumentTitleProductName} from '@app/features/window/hooks/useFluxerDocumentTitle';
|
||||
import type {InstanceAppPublic} from '@fluxer/instance_bootstrap/src/Types';
|
||||
|
||||
let lastProductName = DEFAULT_APP_SHELL_BRANDING.productName;
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||||
}
|
||||
|
||||
function brandedLink(rel: string): HTMLLinkElement | null {
|
||||
return document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"][data-fluxer-branding="true"]`);
|
||||
}
|
||||
@@ -28,6 +23,22 @@ function removeLink(rel: string): void {
|
||||
brandedLink(rel)?.remove();
|
||||
}
|
||||
|
||||
function suspendDefaultLinks(rel: string): void {
|
||||
for (const link of document.head.querySelectorAll<HTMLLinkElement>(
|
||||
`link[rel="${rel}"]:not([data-fluxer-branding])`,
|
||||
)) {
|
||||
link.dataset.fluxerDefaultRel = rel;
|
||||
link.rel = '';
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDefaultLinks(rel: string): void {
|
||||
for (const link of document.head.querySelectorAll<HTMLLinkElement>(`link[data-fluxer-default-rel="${rel}"]`)) {
|
||||
link.rel = rel;
|
||||
delete link.dataset.fluxerDefaultRel;
|
||||
}
|
||||
}
|
||||
|
||||
function brandedMeta(name: string): HTMLMetaElement | null {
|
||||
return document.head.querySelector<HTMLMetaElement>(`meta[name="${name}"][data-fluxer-branding="true"]`);
|
||||
}
|
||||
@@ -47,25 +58,16 @@ function removeMeta(name: string): void {
|
||||
brandedMeta(name)?.remove();
|
||||
}
|
||||
|
||||
function replaceTitleProductName(nextProductName: string): void {
|
||||
if (lastProductName === nextProductName) return;
|
||||
const prefix = new RegExp(`^(\\(\\d+\\)\\s+|\\u2022\\s+)?${escapeRegExp(lastProductName)}(?=$| \\| )`, 'u');
|
||||
if (prefix.test(document.title)) {
|
||||
document.title = document.title.replace(prefix, (_match, notificationPrefix: string | undefined) => {
|
||||
return `${notificationPrefix ?? ''}${nextProductName}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyDocumentBranding(productName: string, faviconUrl: string | null, themeColor: string | null): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
replaceTitleProductName(productName);
|
||||
lastProductName = productName;
|
||||
setDocumentTitleProductName(productName);
|
||||
setMeta('application-name', productName);
|
||||
setMeta('apple-mobile-web-app-title', productName);
|
||||
if (faviconUrl === null) {
|
||||
removeLink('icon');
|
||||
restoreDefaultLinks('icon');
|
||||
} else {
|
||||
suspendDefaultLinks('icon');
|
||||
setLink('icon', faviconUrl);
|
||||
}
|
||||
if (themeColor === null) {
|
||||
@@ -75,10 +77,15 @@ function applyDocumentBranding(productName: string, faviconUrl: string | null, t
|
||||
}
|
||||
}
|
||||
|
||||
export function getDocumentFaviconUrl(appPublic: InstanceAppPublic | null): string | null {
|
||||
if (appPublic === null) return DEFAULT_APP_SHELL_BRANDING.faviconUrl;
|
||||
return appPublic.branding.favicon_url ?? appPublic.branding.icon_url;
|
||||
}
|
||||
|
||||
export function applyRuntimeDocumentBranding(appPublic: InstanceAppPublic): void {
|
||||
applyDocumentBranding(
|
||||
appPublic.branding.product_name,
|
||||
appPublic.branding.favicon_url ?? appPublic.branding.icon_url,
|
||||
getDocumentFaviconUrl(appPublic),
|
||||
appPublic.branding.theme_color,
|
||||
);
|
||||
}
|
||||
@@ -86,7 +93,7 @@ export function applyRuntimeDocumentBranding(appPublic: InstanceAppPublic): void
|
||||
export function applyDefaultAppShellDocumentBranding(): void {
|
||||
applyDocumentBranding(
|
||||
DEFAULT_APP_SHELL_BRANDING.productName,
|
||||
DEFAULT_APP_SHELL_BRANDING.faviconUrl,
|
||||
getDocumentFaviconUrl(null),
|
||||
DEFAULT_APP_SHELL_BRANDING.themeColor,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
|
||||
import Config from '@app/features/app/config/Config';
|
||||
import {DESKTOP_DOWNLOAD_URL} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {
|
||||
shouldShowNativeDesktopUpdateDownloadProgress,
|
||||
shouldShowNativeDesktopUpdateInApp,
|
||||
} from '@app/features/app/utils/UpdaterPlatformUtils';
|
||||
import {WORKER_NAVIGATION_CACHE_PREFIX} from '@app/features/platform/service_worker/WorkerCacheCleanup';
|
||||
import {getProtectedCacheStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import type {UpdaterContext, UpdaterDownloadOption, UpdaterEvent} from '@app/features/platform/types/Electron';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {getClientInfo} from '@app/features/platform/utils/ClientInfo';
|
||||
import {flushPendingPersistWrites} from '@app/features/platform/utils/MobXPersistence';
|
||||
import {initializeStore} from '@app/features/platform/utils/StoreInitialization';
|
||||
import {
|
||||
downloadWithNative,
|
||||
@@ -20,13 +17,12 @@ import {
|
||||
openExternalUrl,
|
||||
} from '@app/features/ui/utils/NativeUtils';
|
||||
import {
|
||||
pushDesktopUpdateAvailableModal,
|
||||
pushDesktopUpdateDownloadFailedModal,
|
||||
pushDesktopUpdateInstallFailedModal,
|
||||
pushManualUpdateAvailableModal,
|
||||
pushUnsupportedUpdateModal,
|
||||
pushUpdateAvailableModal,
|
||||
pushUpdateCheckFailedModal,
|
||||
pushUpdateReadyModal,
|
||||
pushUpToDateModal,
|
||||
} from '@app/features/updater/commands/UpdaterModalCommands';
|
||||
import {
|
||||
@@ -35,7 +31,6 @@ import {
|
||||
getUpdaterMachineStateValue,
|
||||
getUpdaterUpdateType,
|
||||
hasManualNativeDownload,
|
||||
type NativeDownloadProgress,
|
||||
type NativeUpdateInfo,
|
||||
transitionUpdaterMachineSnapshot,
|
||||
type UpdateInfo,
|
||||
@@ -47,16 +42,9 @@ import {
|
||||
} from '@app/features/updater/state/UpdaterStateMachine';
|
||||
import {buildLinuxManualUpdateOptions} from '@app/features/updater/utils/LinuxManualUpdateOptions';
|
||||
import type {UpdaterEvent as NativeUpdaterEvent} from '@app/types/electron.d';
|
||||
import type {DesktopPendingModuleUpdate} from '@fluxer/desktop_ipc/src/ModuleContract';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {makeAutoObservable, runInAction} from 'mobx';
|
||||
|
||||
export type {NativeDownloadProgress, NativeUpdateInfo, UpdateInfo, UpdaterState, UpdateType, WebUpdateInfo};
|
||||
|
||||
export const DOWNLOADING_UPDATE_DESCRIPTOR = msg({
|
||||
message: 'Downloading desktop update…',
|
||||
comment: 'Short desktop updater status label.',
|
||||
});
|
||||
export type {NativeUpdateInfo, UpdateInfo, UpdaterState, UpdateType, WebUpdateInfo};
|
||||
|
||||
const logger = new Logger('Updater');
|
||||
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
||||
@@ -109,32 +97,15 @@ function normalizeUpdaterEvent(event: NativeUpdaterEvent): UpdaterEvent | null {
|
||||
type: 'available',
|
||||
context,
|
||||
version: event.version ?? null,
|
||||
downloadSize: event.downloadSize ?? null,
|
||||
downloadStarted: event.downloadStarted ?? true,
|
||||
downloadUrl: event.downloadUrl,
|
||||
downloadOptions: event.downloadOptions,
|
||||
};
|
||||
case 'not-available':
|
||||
return {type: 'not-available', context};
|
||||
case 'downloaded':
|
||||
return {type: 'downloaded', context, version: event.version ?? null};
|
||||
return {type: 'downloaded', context};
|
||||
case 'progress':
|
||||
if (
|
||||
typeof event.percent !== 'number' ||
|
||||
typeof event.transferred !== 'number' ||
|
||||
typeof event.total !== 'number' ||
|
||||
typeof event.bytesPerSecond !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'progress',
|
||||
context,
|
||||
percent: event.percent,
|
||||
transferred: event.transferred,
|
||||
total: event.total,
|
||||
bytesPerSecond: event.bytesPerSecond,
|
||||
};
|
||||
return null;
|
||||
case 'error':
|
||||
return {
|
||||
type: 'error',
|
||||
@@ -164,9 +135,12 @@ class Updater {
|
||||
private backgroundCheckInterval: number | null = null;
|
||||
private backgroundCheckCleanups: Array<() => void> = [];
|
||||
private unsubscribeNativeEvents: (() => void) | null = null;
|
||||
private unsubscribeModuleUpdates: (() => void) | null = null;
|
||||
moduleUpdate: DesktopPendingModuleUpdate | null = null;
|
||||
private updateReadyNagbarDismissedVersion: string | null = null;
|
||||
private unsubscribeDesktopUpdate: (() => void) | null = null;
|
||||
private desktopUpdateReported = false;
|
||||
private olderShellModuleUpdateReady = false;
|
||||
private olderShellUpdateDownloaded = false;
|
||||
private olderShellInstallWhenDownloaded = false;
|
||||
private desktopUpdateStarting = false;
|
||||
private pendingManualDownloadRefreshes = 0;
|
||||
private checkInProgress = false;
|
||||
|
||||
@@ -184,10 +158,6 @@ class Updater {
|
||||
return this.snapshot.context.updateInfo;
|
||||
}
|
||||
|
||||
get downloadProgress(): NativeDownloadProgress | null {
|
||||
return this.snapshot.context.downloadProgress;
|
||||
}
|
||||
|
||||
get lastCheckedAt(): number | null {
|
||||
return this.snapshot.context.lastCheckedAt;
|
||||
}
|
||||
@@ -215,60 +185,28 @@ class Updater {
|
||||
return this.snapshot.context.manualNativeDownloadInFlight;
|
||||
}
|
||||
|
||||
get desktopUpdateAvailable(): boolean {
|
||||
return this.desktopUpdateReported || this.olderShellModuleUpdateReady || this.olderShellUpdateAvailable;
|
||||
}
|
||||
|
||||
private get olderShellUpdateAvailable(): boolean {
|
||||
const electronApi = getElectronAPI();
|
||||
if (electronApi == null || electronApi.desktopUpdate != null || electronApi.updaterInstall == null) {
|
||||
return false;
|
||||
}
|
||||
return this.olderShellUpdateDownloaded || (this.updateInfo.native.available && !this.hasManualNativeDownload);
|
||||
}
|
||||
|
||||
get hasUpdate(): boolean {
|
||||
return this.moduleUpdateReady || this.updateInfo.native.available || this.updateInfo.web.available;
|
||||
}
|
||||
|
||||
get moduleUpdateReady(): boolean {
|
||||
return this.moduleUpdate != null;
|
||||
}
|
||||
|
||||
get nativeUpdatePending(): boolean {
|
||||
return this.updateInfo.native.available && !this.updateInfo.native.downloaded;
|
||||
}
|
||||
|
||||
get nativeUpdateReady(): boolean {
|
||||
return this.updateInfo.native.available && this.updateInfo.native.downloaded;
|
||||
}
|
||||
|
||||
get shouldShowUpdateReadyNagbar(): boolean {
|
||||
return (
|
||||
this.nativeUpdateReady &&
|
||||
!this.updateInfo.native.installing &&
|
||||
this.updateReadyNagbarDismissedVersion !== this.updateReadyNagbarVersionKey
|
||||
);
|
||||
}
|
||||
|
||||
private get updateReadyNagbarVersionKey(): string {
|
||||
return this.updateInfo.native.version ?? 'unknown';
|
||||
}
|
||||
|
||||
get nativeDownloadInFlight(): boolean {
|
||||
return this.updateInfo.native.downloading && !this.updateInfo.native.downloaded;
|
||||
}
|
||||
|
||||
get nativeDownloadProgressSupported(): boolean {
|
||||
return shouldShowNativeDesktopUpdateDownloadProgress(getElectronAPI()?.platform);
|
||||
return this.desktopUpdateAvailable || this.updateInfo.native.available || this.updateInfo.web.available;
|
||||
}
|
||||
|
||||
get hasManualNativeDownload(): boolean {
|
||||
return hasManualNativeDownload(this.snapshot);
|
||||
}
|
||||
|
||||
get nativeAwaitingDownload(): boolean {
|
||||
if (this.hasManualNativeDownload) return false;
|
||||
return (
|
||||
this.updateInfo.native.available && !this.updateInfo.native.downloaded && !this.updateInfo.native.downloading
|
||||
);
|
||||
}
|
||||
|
||||
get nativeManualUpdateAvailable(): boolean {
|
||||
return (
|
||||
this.updateInfo.native.available &&
|
||||
!this.updateInfo.native.downloaded &&
|
||||
!this.updateInfo.native.downloading &&
|
||||
this.hasManualNativeDownload
|
||||
);
|
||||
return this.updateInfo.native.available && this.hasManualNativeDownload;
|
||||
}
|
||||
|
||||
get state(): UpdaterState {
|
||||
@@ -295,7 +233,7 @@ class Updater {
|
||||
}
|
||||
if (this.isNative) {
|
||||
await this.bootstrapNative();
|
||||
await this.bootstrapModuleUpdates();
|
||||
await this.bootstrapDesktopUpdate();
|
||||
}
|
||||
this.startBackgroundChecks();
|
||||
void this.checkForUpdates(false);
|
||||
@@ -315,43 +253,98 @@ class Updater {
|
||||
this.subscribeToNativeEvents();
|
||||
}
|
||||
|
||||
private async bootstrapModuleUpdates(): Promise<void> {
|
||||
private async bootstrapDesktopUpdate(): Promise<void> {
|
||||
const electronApi = getElectronAPI();
|
||||
const desktopUpdate = electronApi?.desktopUpdate;
|
||||
if (desktopUpdate == null) {
|
||||
await this.bootstrapOlderShellModuleUpdate();
|
||||
return;
|
||||
}
|
||||
this.unsubscribeDesktopUpdate = desktopUpdate.onStateChanged((state) => {
|
||||
runInAction(() => {
|
||||
this.desktopUpdateReported = state.available;
|
||||
});
|
||||
});
|
||||
await this.refreshDesktopUpdateState();
|
||||
}
|
||||
|
||||
private async bootstrapOlderShellModuleUpdate(): Promise<void> {
|
||||
const desktopModules = getElectronAPI()?.desktopModules;
|
||||
const onPendingUpdateChanged = desktopModules?.onPendingUpdateChanged;
|
||||
const pendingUpdate = desktopModules?.pendingUpdate;
|
||||
if (onPendingUpdateChanged == null || pendingUpdate == null) return;
|
||||
this.unsubscribeModuleUpdates = onPendingUpdateChanged((pending) => {
|
||||
this.unsubscribeDesktopUpdate = onPendingUpdateChanged((pending) => {
|
||||
runInAction(() => {
|
||||
this.moduleUpdate = pending;
|
||||
this.olderShellModuleUpdateReady = pending != null;
|
||||
});
|
||||
});
|
||||
try {
|
||||
const pending = await pendingUpdate();
|
||||
runInAction(() => {
|
||||
this.moduleUpdate = pending;
|
||||
this.olderShellModuleUpdateReady = pending != null;
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read the pending desktop module update', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async applyModuleUpdate(): Promise<void> {
|
||||
const applyPendingUpdate = getElectronAPI()?.desktopModules?.applyPendingUpdate;
|
||||
if (applyPendingUpdate == null) return;
|
||||
private async refreshDesktopUpdateState(): Promise<void> {
|
||||
const desktopUpdate = getElectronAPI()?.desktopUpdate;
|
||||
if (desktopUpdate == null) return;
|
||||
try {
|
||||
await applyPendingUpdate();
|
||||
const state = await desktopUpdate.state();
|
||||
runInAction(() => {
|
||||
this.desktopUpdateReported = state.available;
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Failed to apply the pending desktop module update', error);
|
||||
logger.warn('Failed to read the desktop update state', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async startDesktopUpdate(): Promise<void> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (electronApi == null || this.desktopUpdateStarting) return;
|
||||
this.desktopUpdateStarting = true;
|
||||
try {
|
||||
flushPendingPersistWrites();
|
||||
if (electronApi.desktopUpdate != null) {
|
||||
await electronApi.desktopUpdate.start();
|
||||
} else if (this.olderShellModuleUpdateReady) {
|
||||
await electronApi.desktopModules?.applyPendingUpdate?.();
|
||||
} else if (this.olderShellUpdateDownloaded) {
|
||||
await electronApi.updaterInstall?.();
|
||||
} else {
|
||||
this.olderShellInstallWhenDownloaded = true;
|
||||
await electronApi.updaterDownload?.('user');
|
||||
}
|
||||
} catch (error) {
|
||||
this.olderShellInstallWhenDownloaded = false;
|
||||
logger.warn('Failed to start the desktop update', error);
|
||||
} finally {
|
||||
this.desktopUpdateStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleOlderShellUpdateDownloaded(): void {
|
||||
this.olderShellUpdateDownloaded = true;
|
||||
if (!this.olderShellInstallWhenDownloaded) return;
|
||||
this.olderShellInstallWhenDownloaded = false;
|
||||
void getElectronAPI()
|
||||
?.updaterInstall?.()
|
||||
.catch((error: unknown) => {
|
||||
logger.warn('Failed to install the downloaded desktop update', error);
|
||||
});
|
||||
}
|
||||
|
||||
private subscribeToNativeEvents(): void {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return;
|
||||
this.unsubscribeNativeEvents = electronApi.onUpdaterEvent((event) => {
|
||||
const updaterEvent = normalizeUpdaterEvent(event);
|
||||
if (!updaterEvent) {
|
||||
logger.warn('Ignored malformed native updater event', {event});
|
||||
if (event.type !== 'progress') {
|
||||
logger.warn('Ignored malformed native updater event', {event});
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.handleNativeEvent(updaterEvent);
|
||||
@@ -370,35 +363,17 @@ class Updater {
|
||||
this.pendingManualDownloadRefreshes -= 1;
|
||||
}
|
||||
const isUserCheck = event.context === 'user' && !isManualDownloadRefreshResult;
|
||||
const isBackgroundOrFocusCheck = event.context === 'background' || event.context === 'focus';
|
||||
const shouldSurfaceNativeDesktopUpdate = this.shouldSurfaceNativeDesktopUpdate();
|
||||
const shouldShowImmediateUserResult = isUserCheck && !this.checkInProgress;
|
||||
switch (event.type) {
|
||||
case 'checking':
|
||||
this.transition({type: 'check.started'});
|
||||
break;
|
||||
case 'available': {
|
||||
const downloadStarted = event.downloadStarted ?? true;
|
||||
const manualDownloadOptions = this.resolveManualNativeDownloadOptions(event, event.downloadOptions ?? []);
|
||||
const manualDownloadUrl = manualDownloadOptions[0]?.url ?? event.downloadUrl ?? null;
|
||||
if (!shouldSurfaceNativeDesktopUpdate) {
|
||||
this.transition({
|
||||
type: 'native.hidden',
|
||||
reason: 'platform',
|
||||
downloadUrl: manualDownloadUrl,
|
||||
now: Date.now(),
|
||||
});
|
||||
if (isUserCheck) {
|
||||
pushUnsupportedUpdateModal('platform', manualDownloadUrl ?? DESKTOP_DOWNLOAD_URL);
|
||||
}
|
||||
break;
|
||||
}
|
||||
this.transition({
|
||||
type: 'native.available',
|
||||
version: event.version ?? null,
|
||||
downloadSize: event.downloadSize ?? null,
|
||||
downloadStarted,
|
||||
downloadUrl: manualDownloadUrl,
|
||||
downloadUrl: manualDownloadOptions[0]?.url ?? event.downloadUrl ?? null,
|
||||
downloadOptions: manualDownloadOptions,
|
||||
});
|
||||
if (shouldShowImmediateUserResult) {
|
||||
@@ -408,55 +383,25 @@ class Updater {
|
||||
}
|
||||
case 'not-available':
|
||||
this.transition({type: 'native.notAvailable', now: Date.now()});
|
||||
if (isUserCheck && !this.checkInProgress) {
|
||||
if (shouldShowImmediateUserResult) {
|
||||
this.showCurrentUpdateState();
|
||||
}
|
||||
break;
|
||||
case 'error': {
|
||||
const phase = event.phase ?? 'check';
|
||||
if (isBackgroundOrFocusCheck) {
|
||||
logger.debug('Background update failed silently:', event.message);
|
||||
case 'downloaded':
|
||||
this.handleOlderShellUpdateDownloaded();
|
||||
break;
|
||||
case 'error':
|
||||
this.olderShellInstallWhenDownloaded = false;
|
||||
if (isUserCheck) {
|
||||
logger.warn('Update check error:', event.message);
|
||||
} else {
|
||||
logger.warn(`Update ${phase} error:`, event.message);
|
||||
logger.debug('Background update check failed silently:', event.message);
|
||||
}
|
||||
this.transition({type: 'native.error'});
|
||||
if (isUserCheck) {
|
||||
if (phase === 'download') {
|
||||
pushDesktopUpdateDownloadFailedModal();
|
||||
} else if (phase === 'install') {
|
||||
pushDesktopUpdateInstallFailedModal();
|
||||
} else {
|
||||
pushUpdateCheckFailedModal();
|
||||
}
|
||||
pushUpdateCheckFailedModal();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'downloaded':
|
||||
if (!shouldSurfaceNativeDesktopUpdate) {
|
||||
this.transition({
|
||||
type: 'native.hidden',
|
||||
reason: 'platform',
|
||||
downloadUrl: null,
|
||||
now: Date.now(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
this.transition({type: 'native.downloaded', version: event.version ?? null});
|
||||
break;
|
||||
case 'progress':
|
||||
if (!shouldSurfaceNativeDesktopUpdate || !this.nativeDownloadProgressSupported) {
|
||||
break;
|
||||
}
|
||||
this.transition({
|
||||
type: 'native.progress',
|
||||
progress: {
|
||||
percent: event.percent,
|
||||
transferred: event.transferred,
|
||||
total: event.total,
|
||||
bytesPerSecond: event.bytesPerSecond,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'unsupported':
|
||||
this.transition({
|
||||
type: 'native.unsupported',
|
||||
@@ -464,7 +409,7 @@ class Updater {
|
||||
downloadUrl: event.downloadUrl ?? null,
|
||||
now: Date.now(),
|
||||
});
|
||||
if (isUserCheck) {
|
||||
if (shouldShowImmediateUserResult) {
|
||||
pushUnsupportedUpdateModal(event.reason ?? 'platform', event.downloadUrl ?? null);
|
||||
}
|
||||
break;
|
||||
@@ -475,7 +420,7 @@ class Updater {
|
||||
event: Extract<UpdaterEvent, {type: 'available'}>,
|
||||
options: ReadonlyArray<UpdaterDownloadOption>,
|
||||
): ReadonlyArray<UpdaterDownloadOption> {
|
||||
if ((event.downloadStarted ?? true) || getElectronAPI()?.platform !== 'linux') {
|
||||
if (getElectronAPI()?.platform !== 'linux') {
|
||||
return options;
|
||||
}
|
||||
return buildLinuxManualUpdateOptions({
|
||||
@@ -521,15 +466,10 @@ class Updater {
|
||||
private shouldRunNativeCheck(userInitiated: boolean): boolean {
|
||||
if (!this.isNative) return false;
|
||||
if (this.nativeUnsupported && !userInitiated) return false;
|
||||
if (this.nativeDownloadInFlight || this.updateInfo.native.installing) return false;
|
||||
if (userInitiated) return true;
|
||||
return !this.updateInfo.native.available;
|
||||
}
|
||||
|
||||
private shouldSurfaceNativeDesktopUpdate(): boolean {
|
||||
return shouldShowNativeDesktopUpdateInApp(getElectronAPI()?.platform);
|
||||
}
|
||||
|
||||
async checkForUpdates(force = false, userInitiated = false): Promise<void> {
|
||||
if (this.checkInProgress) {
|
||||
return;
|
||||
@@ -555,6 +495,9 @@ class Updater {
|
||||
if (webResult) {
|
||||
this.transition({type: 'web.checked', available: webResult.available, version: webResult.version});
|
||||
}
|
||||
if (userInitiated && shouldCheckNative) {
|
||||
await this.refreshDesktopUpdateState();
|
||||
}
|
||||
if (userInitiated && (!shouldCheckNative || (!this.isChecking && !this.nativeCheckFailed))) {
|
||||
this.showCurrentUpdateState();
|
||||
}
|
||||
@@ -628,32 +571,8 @@ class Updater {
|
||||
|
||||
async applyUpdate(): Promise<void> {
|
||||
if (!this.hasUpdate) return;
|
||||
if (this.moduleUpdateReady) {
|
||||
await this.applyModuleUpdate();
|
||||
return;
|
||||
}
|
||||
const electronApi = getElectronAPI();
|
||||
if (this.isNative && this.updateInfo.native.downloaded && electronApi) {
|
||||
if (this.updateInfo.native.installing) {
|
||||
logger.debug('Install already in progress; ignoring duplicate click.');
|
||||
return;
|
||||
}
|
||||
this.transition({type: 'native.install.started'});
|
||||
logger.info('Installing downloaded native update...');
|
||||
try {
|
||||
await electronApi.updaterInstall();
|
||||
} catch (error) {
|
||||
logger.warn('Native update install failed', error);
|
||||
this.transition({type: 'native.install.failed'});
|
||||
pushDesktopUpdateInstallFailedModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.isNative && this.nativeAwaitingDownload && electronApi?.updaterDownload) {
|
||||
await this.startNativeDownload();
|
||||
return;
|
||||
}
|
||||
if (this.isNative && this.nativeDownloadInFlight) {
|
||||
if (this.desktopUpdateAvailable) {
|
||||
await this.startDesktopUpdate();
|
||||
return;
|
||||
}
|
||||
if (this.isNative && this.nativeUnsupported?.reason === 'managed-package' && !this.updateInfo.web.available) {
|
||||
@@ -683,34 +602,13 @@ class Updater {
|
||||
}
|
||||
}
|
||||
|
||||
async startNativeDownload(userInitiated = true): Promise<void> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi?.updaterDownload) return;
|
||||
if (!this.updateInfo.native.available) return;
|
||||
if (this.updateInfo.native.downloading || this.updateInfo.native.downloaded) return;
|
||||
this.transition({
|
||||
type: 'native.download.started',
|
||||
progressSupported: this.nativeDownloadProgressSupported,
|
||||
total: this.updateInfo.native.downloadSize,
|
||||
});
|
||||
try {
|
||||
await electronApi.updaterDownload(userInitiated ? 'user' : 'background');
|
||||
} catch (error) {
|
||||
logger.warn('Native update download failed', error);
|
||||
this.transition({type: 'native.download.failed'});
|
||||
if (userInitiated) {
|
||||
pushDesktopUpdateDownloadFailedModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private showCurrentUpdateState(): void {
|
||||
if (this.nativeManualUpdateAvailable) {
|
||||
this.showManualNativeUpdateModal();
|
||||
if (this.desktopUpdateAvailable) {
|
||||
pushDesktopUpdateAvailableModal(() => this.startDesktopUpdate());
|
||||
return;
|
||||
}
|
||||
if (this.nativeUpdateReady) {
|
||||
pushUpdateReadyModal(this.updateInfo.native.version, this.applyUpdate);
|
||||
if (this.nativeManualUpdateAvailable) {
|
||||
this.showManualNativeUpdateModal();
|
||||
return;
|
||||
}
|
||||
if (this.hasUpdate) {
|
||||
@@ -816,10 +714,6 @@ class Updater {
|
||||
}
|
||||
}
|
||||
|
||||
dismissUpdateReadyNagbar(): void {
|
||||
this.updateReadyNagbarDismissedVersion = this.updateReadyNagbarVersionKey;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.transition({type: 'reset'});
|
||||
}
|
||||
@@ -829,11 +723,14 @@ class Updater {
|
||||
this.unsubscribeNativeEvents();
|
||||
this.unsubscribeNativeEvents = null;
|
||||
}
|
||||
if (this.unsubscribeModuleUpdates) {
|
||||
this.unsubscribeModuleUpdates();
|
||||
this.unsubscribeModuleUpdates = null;
|
||||
if (this.unsubscribeDesktopUpdate) {
|
||||
this.unsubscribeDesktopUpdate();
|
||||
this.unsubscribeDesktopUpdate = null;
|
||||
}
|
||||
this.moduleUpdate = null;
|
||||
this.desktopUpdateReported = false;
|
||||
this.olderShellModuleUpdateReady = false;
|
||||
this.olderShellUpdateDownloaded = false;
|
||||
this.olderShellInstallWhenDownloaded = false;
|
||||
if (this.backgroundCheckInterval != null) {
|
||||
window.clearInterval(this.backgroundCheckInterval);
|
||||
this.backgroundCheckInterval = null;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export function shouldShowNativeDesktopUpdateInApp(platform: string | null | undefined): boolean {
|
||||
return platform === 'darwin' || platform === 'win32' || platform === 'linux';
|
||||
}
|
||||
|
||||
export function shouldShowNativeDesktopUpdateDownloadProgress(platform: string | null | undefined): boolean {
|
||||
return platform === 'win32' || platform === 'linux';
|
||||
}
|
||||
@@ -102,6 +102,14 @@ class LimitsClass {
|
||||
return LimitResolver.resolveFree(key, fallback);
|
||||
}
|
||||
|
||||
hasStockFeature(key: LimitKey, fallback: boolean): boolean {
|
||||
return this.getStockValue(key, fallback ? 1 : 0) > 0;
|
||||
}
|
||||
|
||||
hasRestrictedFeature(key: LimitKey, fallback: boolean): boolean {
|
||||
return this.getRestrictedValue(key, fallback ? 1 : 0) > 0;
|
||||
}
|
||||
|
||||
getPremiumValue(key: LimitKey, fallback: number): number {
|
||||
return this.getStockValue(key, fallback);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {runtimeInstanceKey} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import type {Account} from '@app/features/platform/state/AuthSession';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import {getDefaultAvatarAssetURL} from '@app/features/user/utils/DefaultAvatars';
|
||||
@@ -16,12 +17,25 @@ function defaultAccountAvatarURL(account: Account, size?: MediaProxyImageSize):
|
||||
|
||||
function resolveAccountAvatarURL(account: Account, size?: MediaProxyImageSize): string {
|
||||
const avatar = account.userData?.avatar ?? null;
|
||||
const mediaEndpoint = account.instance?.mediaEndpoint ?? null;
|
||||
if (avatar == null || avatar.length === 0 || mediaEndpoint == null || mediaEndpoint.length === 0) {
|
||||
const instance = account.instance ?? null;
|
||||
const mediaEndpoint = instance?.mediaEndpoint ?? null;
|
||||
const instanceKey = instance == null ? null : runtimeInstanceKey(instance);
|
||||
if (
|
||||
avatar == null ||
|
||||
avatar.length === 0 ||
|
||||
mediaEndpoint == null ||
|
||||
mediaEndpoint.length === 0 ||
|
||||
instanceKey == null
|
||||
) {
|
||||
return defaultAccountAvatarURL(account, size);
|
||||
}
|
||||
try {
|
||||
return AvatarUtils.getUserAvatarURLWithProxy({id: account.userId, avatar}, mediaEndpoint, false, size);
|
||||
return AvatarUtils.getUserAvatarURLForInstance(
|
||||
{id: account.userId, avatar},
|
||||
{mediaEndpoint, instanceKey},
|
||||
false,
|
||||
size,
|
||||
);
|
||||
} catch {
|
||||
return defaultAccountAvatarURL(account, size);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfigSnapshot';
|
||||
import {
|
||||
resolveInstanceAssetUrl,
|
||||
resolveInstanceBrandIconUrl,
|
||||
resolveInstanceProductName,
|
||||
} from '@app/features/auth/InstanceBranding';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
type Branding = RuntimeConfigSnapshot['appPublic']['branding'];
|
||||
|
||||
function snapshot(branding: Partial<Branding>, origin = 'https://chat.example.org') {
|
||||
return {
|
||||
apiEndpoint: `${origin}/api`,
|
||||
apiPublicEndpoint: `${origin}/api`,
|
||||
mediaEndpoint: `${origin}/media`,
|
||||
staticCdnEndpoint: origin,
|
||||
webAppEndpoint: origin,
|
||||
appPublic: {
|
||||
branding: {
|
||||
product_name: 'Example Chat',
|
||||
icon_url: null,
|
||||
symbol_url: null,
|
||||
logo_url: null,
|
||||
wordmark_url: null,
|
||||
favicon_url: null,
|
||||
theme_color: null,
|
||||
status_page_url: null,
|
||||
status_page_incident_history_url: null,
|
||||
premium_product_name: 'Premium',
|
||||
premium_info_url: null,
|
||||
...branding,
|
||||
},
|
||||
setup: {configured: true, admin_url: null},
|
||||
legal: {terms_url: null, privacy_url: null},
|
||||
registration: {collect_date_of_birth: false},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('instance branding', () => {
|
||||
test('prefers the symbol, then the icon, then the logo', () => {
|
||||
const base = 'https://chat.example.org/media/branding';
|
||||
expect(
|
||||
resolveInstanceBrandIconUrl(
|
||||
snapshot({symbol_url: `${base}/s.png`, icon_url: `${base}/i.png`, logo_url: `${base}/l.png`}),
|
||||
),
|
||||
).toBe(`${base}/s.png`);
|
||||
expect(resolveInstanceBrandIconUrl(snapshot({icon_url: `${base}/i.png`, logo_url: `${base}/l.png`}))).toBe(
|
||||
`${base}/i.png`,
|
||||
);
|
||||
expect(resolveInstanceBrandIconUrl(snapshot({logo_url: `${base}/l.png`}))).toBe(`${base}/l.png`);
|
||||
expect(resolveInstanceBrandIconUrl(snapshot({}))).toBeNull();
|
||||
});
|
||||
|
||||
test('only accepts assets served by the instance itself', () => {
|
||||
const s = snapshot({});
|
||||
expect(resolveInstanceAssetUrl(s, '/media/branding/icon.png')).toBe(
|
||||
'https://chat.example.org/media/branding/icon.png',
|
||||
);
|
||||
expect(resolveInstanceAssetUrl(s, 'https://tracker.example.net/icon.png')).toBeNull();
|
||||
expect(resolveInstanceAssetUrl(s, 'javascript:alert(1)')).toBeNull();
|
||||
expect(resolveInstanceAssetUrl(s, 'https://user:[email protected]/icon.png')).toBeNull();
|
||||
expect(resolveInstanceAssetUrl(s, 'http://chat.example.org/icon.png')).toBeNull();
|
||||
});
|
||||
|
||||
test('a cleartext instance may serve its own assets over http', () => {
|
||||
const s = snapshot({icon_url: 'http://192.168.1.20:8080/media/icon.png'}, 'http://192.168.1.20:8080');
|
||||
expect(resolveInstanceBrandIconUrl(s)).toBe('http://192.168.1.20:8080/media/icon.png');
|
||||
});
|
||||
|
||||
test('product name ignores blank values', () => {
|
||||
expect(resolveInstanceProductName(snapshot({product_name: ' Example Chat '}))).toBe('Example Chat');
|
||||
expect(resolveInstanceProductName(snapshot({product_name: ' '}))).toBeNull();
|
||||
expect(resolveInstanceProductName(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfigSnapshot';
|
||||
|
||||
type BrandingSnapshot = Pick<
|
||||
RuntimeConfigSnapshot,
|
||||
'apiEndpoint' | 'apiPublicEndpoint' | 'mediaEndpoint' | 'staticCdnEndpoint' | 'webAppEndpoint' | 'appPublic'
|
||||
>;
|
||||
|
||||
const ASSET_PROTOCOLS: ReadonlySet<string> = new Set(['https:', 'http:']);
|
||||
|
||||
function originOf(value: string | null | undefined): string | null {
|
||||
if (value == null || value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function instanceAssetOrigins(snapshot: BrandingSnapshot): ReadonlySet<string> {
|
||||
const origins = new Set<string>();
|
||||
for (const endpoint of [
|
||||
snapshot.mediaEndpoint,
|
||||
snapshot.staticCdnEndpoint,
|
||||
snapshot.webAppEndpoint,
|
||||
snapshot.apiEndpoint,
|
||||
snapshot.apiPublicEndpoint,
|
||||
]) {
|
||||
const origin = originOf(endpoint);
|
||||
if (origin != null && origin !== 'null') {
|
||||
origins.add(origin);
|
||||
}
|
||||
}
|
||||
return origins;
|
||||
}
|
||||
|
||||
export function resolveInstanceAssetUrl(
|
||||
snapshot: BrandingSnapshot,
|
||||
candidate: string | null | undefined,
|
||||
): string | null {
|
||||
const trimmed = candidate?.trim();
|
||||
if (trimmed == null || trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const base = snapshot.mediaEndpoint || snapshot.webAppEndpoint;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed, base);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!ASSET_PROTOCOLS.has(url.protocol) || url.username !== '' || url.password !== '') {
|
||||
return null;
|
||||
}
|
||||
if (!instanceAssetOrigins(snapshot).has(url.origin)) {
|
||||
return null;
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export function resolveInstanceBrandIconUrl(snapshot: BrandingSnapshot | null | undefined): string | null {
|
||||
const branding = snapshot?.appPublic?.branding;
|
||||
if (snapshot == null || branding == null) {
|
||||
return null;
|
||||
}
|
||||
for (const candidate of [branding.symbol_url, branding.icon_url, branding.logo_url]) {
|
||||
const url = resolveInstanceAssetUrl(snapshot, candidate);
|
||||
if (url != null) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveInstanceProductName(snapshot: BrandingSnapshot | null | undefined): string | null {
|
||||
const name = snapshot?.appPublic?.branding?.product_name?.trim();
|
||||
return name == null || name.length === 0 ? null : name;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import InstanceSnapshotStore from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfigSnapshot';
|
||||
import {resolveAccountInstanceKey} from '@app/features/auth/AccountDisplayUtils';
|
||||
import Accounts from '@app/features/auth/state/Accounts';
|
||||
|
||||
export function findInstanceSnapshot(instanceKey: string): RuntimeConfigSnapshot | null {
|
||||
const cached = InstanceSnapshotStore.get(instanceKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
for (const account of Accounts.orderedAccounts) {
|
||||
if (account.instance != null && resolveAccountInstanceKey(account) === instanceKey) {
|
||||
return account.instance;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -131,14 +131,17 @@ interface RecoveryKitIssued {
|
||||
|
||||
export type RecoverAccountResponse = (AuthTokenResponse | MfaLoginResponse) & RecoveryKitIssued;
|
||||
|
||||
export type DesktopHandoffReturnMethod = 'deep_link' | 'code';
|
||||
|
||||
interface DesktopHandoffInitiateResponse {
|
||||
code: string;
|
||||
expires_at: string;
|
||||
poll_secret?: string;
|
||||
return_method?: DesktopHandoffReturnMethod;
|
||||
}
|
||||
|
||||
interface DesktopHandoffStatusResponse {
|
||||
status: 'pending' | 'completed' | 'expired';
|
||||
status: 'pending' | 'completed' | 'denied' | 'expired';
|
||||
token?: string;
|
||||
user_id?: string;
|
||||
user?: AuthResponseUser | null;
|
||||
@@ -147,6 +150,7 @@ interface DesktopHandoffStatusResponse {
|
||||
interface DesktopHandoffInfoClientInfo {
|
||||
platform?: string | null;
|
||||
os?: string | null;
|
||||
device?: 'mobile' | 'desktop';
|
||||
location?: {
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
@@ -157,6 +161,11 @@ interface DesktopHandoffInfoClientInfo {
|
||||
export interface DesktopHandoffInfoResponse {
|
||||
status: 'pending' | 'expired';
|
||||
client_info?: DesktopHandoffInfoClientInfo | null;
|
||||
return_method?: DesktopHandoffReturnMethod;
|
||||
}
|
||||
|
||||
interface DesktopHandoffCompleteResponse {
|
||||
return_url?: string;
|
||||
}
|
||||
|
||||
export type LoginIdentifier = {email: string; login?: undefined} | {login: string; email?: undefined};
|
||||
@@ -730,21 +739,34 @@ export async function completeDesktopHandoff({
|
||||
code,
|
||||
token,
|
||||
userId,
|
||||
returnMethod,
|
||||
target,
|
||||
}: {
|
||||
code: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
returnMethod: DesktopHandoffReturnMethod;
|
||||
target: InstanceHTTPTarget;
|
||||
}): Promise<void> {
|
||||
await instanceRequest({
|
||||
}): Promise<string | null> {
|
||||
const response = await instanceRequest<DesktopHandoffCompleteResponse | null>({
|
||||
method: 'POST',
|
||||
path: Endpoints.AUTH_HANDOFF_COMPLETE,
|
||||
target,
|
||||
body: {code, user_id: userId},
|
||||
body: {code, user_id: userId, return_method: returnMethod},
|
||||
headers: withAuthLocaleHeader({Authorization: token}),
|
||||
auth: 'none',
|
||||
});
|
||||
return response.body?.return_url ?? null;
|
||||
}
|
||||
|
||||
export async function denyDesktopHandoff({code, target}: {code: string; target: InstanceHTTPTarget}): Promise<void> {
|
||||
await instanceRequest({
|
||||
method: 'POST',
|
||||
path: Endpoints.AUTH_HANDOFF_DENY(code),
|
||||
target,
|
||||
headers: withAuthLocaleHeader(),
|
||||
auth: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDesktopHandoffInfo({
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import FluxerLogoAsset from '@app/media/images/fluxer-logo-color.svg?react';
|
||||
import {GlobeIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {useState} from 'react';
|
||||
|
||||
interface InstanceBrandMarkProps {
|
||||
readonly isOfficial: boolean;
|
||||
readonly iconUrl: string | null;
|
||||
readonly size: number;
|
||||
readonly globeWeight?: 'regular' | 'bold';
|
||||
readonly 'data-flx'?: string;
|
||||
}
|
||||
|
||||
function InstanceBrandImage({
|
||||
url,
|
||||
size,
|
||||
fallback,
|
||||
}: {
|
||||
readonly url: string;
|
||||
readonly size: number;
|
||||
readonly fallback: React.ReactElement;
|
||||
}): React.ReactElement {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) {
|
||||
return fallback;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setFailed(true)}
|
||||
data-flx="auth.components.instance-brand-mark.instance-brand-image.image"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InstanceBrandMark({
|
||||
isOfficial,
|
||||
iconUrl,
|
||||
size,
|
||||
globeWeight = 'regular',
|
||||
'data-flx': dataFlx,
|
||||
}: InstanceBrandMarkProps): React.ReactElement {
|
||||
if (isOfficial) {
|
||||
return <FluxerLogoAsset aria-hidden="true" data-flx={dataFlx} />;
|
||||
}
|
||||
const globe = <GlobeIcon size={remFromPx(size)} weight={globeWeight} aria-hidden="true" data-flx={dataFlx} />;
|
||||
if (iconUrl == null) {
|
||||
return globe;
|
||||
}
|
||||
return <InstanceBrandImage key={iconUrl} url={iconUrl} size={size} fallback={globe} />;
|
||||
}
|
||||
+15
-14
@@ -2,11 +2,10 @@
|
||||
|
||||
import styles from '@app/features/auth/components/accounts/account_switcher_modal/AccountProfilePicker.module.css';
|
||||
import type {AccountInstanceBadgeInfo} from '@app/features/auth/components/accounts/account_switcher_modal/useAccountInstanceBadge';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {InstanceBrandMark} from '@app/features/auth/components/InstanceBrandMark';
|
||||
import {instanceDomainHost} from '@app/features/auth/flow/instance_selector/InstanceDirectoryStorage';
|
||||
import {VerifiedConnectionIcon} from '@app/features/ui/components/icons/VerifiedConnectionIcon';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import FluxerLogoAsset from '@app/media/images/fluxer-logo-color.svg?react';
|
||||
import {GlobeIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
|
||||
interface AccountInstanceBadgeProps {
|
||||
@@ -22,11 +21,15 @@ export function AccountInstanceBadge({
|
||||
}: AccountInstanceBadgeProps): React.ReactElement {
|
||||
let domainLabel = fallbackLabel;
|
||||
let isOfficial = false;
|
||||
let iconUrl: string | null = null;
|
||||
let instanceName: string | null = null;
|
||||
if (badge != null) {
|
||||
domainLabel = badge.title;
|
||||
isOfficial = badge.isOfficial;
|
||||
iconUrl = badge.iconUrl;
|
||||
instanceName = badge.label !== instanceDomainHost(badge.title) ? badge.label : null;
|
||||
}
|
||||
const ariaLabels = [domainLabel];
|
||||
const ariaLabels = instanceName == null ? [domainLabel] : [instanceName, domainLabel];
|
||||
if (isOfficial) {
|
||||
ariaLabels.push(officialLabel);
|
||||
}
|
||||
@@ -39,7 +42,7 @@ export function AccountInstanceBadge({
|
||||
className={styles.instanceTooltipLabel}
|
||||
data-flx="auth.accounts.account-switcher-modal.account-instance-badge.render-tooltip.instance-tooltip-label"
|
||||
>
|
||||
{domainLabel}
|
||||
{instanceName == null ? domainLabel : `${instanceName} (${domainLabel})`}
|
||||
</span>
|
||||
{isOfficial && (
|
||||
<span
|
||||
@@ -69,15 +72,13 @@ export function AccountInstanceBadge({
|
||||
aria-label={ariaLabels.join(', ')}
|
||||
data-flx="auth.accounts.account-switcher-modal.account-instance-badge.instance-badge"
|
||||
>
|
||||
{isOfficial ? (
|
||||
<FluxerLogoAsset data-flx="auth.accounts.account-switcher-modal.account-instance-badge.fluxer-logo-asset" />
|
||||
) : (
|
||||
<GlobeIcon
|
||||
size={remFromPx(15)}
|
||||
weight="bold"
|
||||
data-flx="auth.accounts.account-switcher-modal.account-instance-badge.globe-icon"
|
||||
/>
|
||||
)}
|
||||
<InstanceBrandMark
|
||||
isOfficial={isOfficial}
|
||||
iconUrl={iconUrl}
|
||||
size={15}
|
||||
globeWeight="bold"
|
||||
data-flx="auth.accounts.account-switcher-modal.account-instance-badge.instance-brand-mark"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
+8
-2
@@ -281,14 +281,20 @@
|
||||
box-shadow: 0 0.25rem 0.75rem rgb(0 0 0 / 0.28);
|
||||
}
|
||||
|
||||
.instanceBadge svg,
|
||||
.instanceBadge img {
|
||||
.instanceBadge svg {
|
||||
display: block;
|
||||
width: 72%;
|
||||
height: 72%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.instanceBadge img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.instanceTooltip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
+9
-2
@@ -6,6 +6,7 @@ import {
|
||||
normalizeInstanceName,
|
||||
resolveInstanceLabel,
|
||||
} from '@app/features/auth/flow/instance_selector/InstanceDirectoryStorage';
|
||||
import {resolveInstanceBrandIconUrl} from '@app/features/auth/InstanceBranding';
|
||||
import type {Account} from '@app/features/platform/state/AuthSession';
|
||||
import {isOfficialInstanceHost, OFFICIAL_INSTANCE_NAME} from '@fluxer/instance_bootstrap/src/OfficialInstance';
|
||||
import {useMemo} from 'react';
|
||||
@@ -14,6 +15,7 @@ export interface AccountInstanceBadgeInfo {
|
||||
readonly label: string;
|
||||
readonly title: string;
|
||||
readonly isOfficial: boolean;
|
||||
readonly iconUrl: string | null;
|
||||
}
|
||||
|
||||
function findKnownInstance(instanceKey: string, knownInstances: ReadonlyArray<InstanceInfo>): InstanceInfo | null {
|
||||
@@ -44,10 +46,15 @@ function getAccountInstanceBadgeInfo(
|
||||
}
|
||||
const isOfficial = isOfficialInstanceHost(instanceKey);
|
||||
if (isOfficial) {
|
||||
return {label: OFFICIAL_INSTANCE_NAME, title: domain, isOfficial};
|
||||
return {label: OFFICIAL_INSTANCE_NAME, title: domain, isOfficial, iconUrl: null};
|
||||
}
|
||||
const knownInstance = findKnownInstance(instanceKey, knownInstances);
|
||||
return {label: resolveInstanceName(account, knownInstance, domain), title: domain, isOfficial};
|
||||
return {
|
||||
label: resolveInstanceName(account, knownInstance, domain),
|
||||
title: domain,
|
||||
isOfficial,
|
||||
iconUrl: resolveInstanceBrandIconUrl(account.instance),
|
||||
};
|
||||
}
|
||||
|
||||
export function useAccountInstanceBadge(
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {getActiveInstanceProductName} from '@app/features/app/state/ActiveInstanceProductName';
|
||||
import {useAuthorizeParams} from '@app/features/auth/components/pages/oauth_authorize_page/hooks/useAuthorizeParams';
|
||||
import {
|
||||
type BotInviteDestinationOption,
|
||||
@@ -459,7 +459,8 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
}
|
||||
}, [params, onCancelOverride, publicAppState.data, i18n]);
|
||||
const sessionExpiredMessage = useMemo(
|
||||
() => i18n._(YOUR_PRODUCT_SESSION_EXPIRED_SIGN_IN_AGAIN_TO_DESCRIPTOR, {productName: PRODUCT_NAME}),
|
||||
() =>
|
||||
i18n._(YOUR_PRODUCT_SESSION_EXPIRED_SIGN_IN_AGAIN_TO_DESCRIPTOR, {productName: getActiveInstanceProductName()}),
|
||||
[i18n.locale],
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -44,7 +44,7 @@ import {AuthClientPreferencesStep} from '@app/features/auth/flow/client_intro/Au
|
||||
import {AuthClientWelcomeStep} from '@app/features/auth/flow/client_intro/AuthClientWelcomeStep';
|
||||
import {useDesktopClientIntroFlow} from '@app/features/auth/flow/client_intro/useDesktopClientIntroFlow';
|
||||
import DesktopHandoffAccountSelector from '@app/features/auth/flow/DesktopHandoffAccountSelector';
|
||||
import {ConnectedHandoffApprovalFlow} from '@app/features/auth/flow/HandoffApprovalFlow';
|
||||
import {HandoffApprovalFlow} from '@app/features/auth/flow/HandoffApprovalFlow';
|
||||
import IpAuthorizationScreen from '@app/features/auth/flow/IpAuthorizationScreen';
|
||||
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
|
||||
import {getAuthErrorMessage} from '@app/features/auth/hooks/useAuthForm';
|
||||
@@ -992,12 +992,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
);
|
||||
}
|
||||
if (authLoginStep === AuthLoginStep.DESKTOP_HANDOFF_APPROVAL) {
|
||||
return (
|
||||
<ConnectedHandoffApprovalFlow
|
||||
handoff={handoff}
|
||||
data-flx="auth.flow.auth-login-layout.connected-handoff-approval-flow"
|
||||
/>
|
||||
);
|
||||
return <HandoffApprovalFlow handoff={handoff} data-flx="auth.flow.auth-login-layout.handoff-approval-flow" />;
|
||||
}
|
||||
if (authLoginStep === AuthLoginStep.IP_AUTHORIZATION && ipAuthChallenge != null) {
|
||||
const runtimeSnapshot = requireAuthInstanceSnapshot(resolvedAuthRuntimeSnapshot);
|
||||
|
||||
@@ -50,7 +50,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
|
||||
if (!token) {
|
||||
throw new Error('Failed to generate token');
|
||||
}
|
||||
onAccountSelected({token, userId, runtimeSnapshot});
|
||||
onAccountSelected({token, userId, runtimeSnapshot, userData: account.userData});
|
||||
} catch (err) {
|
||||
if (err instanceof SessionExpiredError) {
|
||||
onReLoginAccount(Accounts.getAccount(account.storageKey) ?? account);
|
||||
|
||||
@@ -74,13 +74,7 @@ export const DesktopHandoffMfaStep = observer(function DesktopHandoffMfaStep({
|
||||
if (isHandoff && isApprovalFlowMode(handoff.mode)) {
|
||||
return (
|
||||
<HandoffApprovalFlow
|
||||
mode={handoff.mode}
|
||||
error={handoff.error}
|
||||
clientInfo={handoff.clientInfo}
|
||||
onProceedToCodeInput={handoff.proceedToCodeInput}
|
||||
onSubmitCode={handoff.submitCode}
|
||||
onApprove={handoff.approve}
|
||||
onDeny={handoff.deny}
|
||||
handoff={handoff}
|
||||
onRetry={handleHandoffRetry}
|
||||
data-flx="auth.flow.desktop-handoff-mfa-step.handoff-approval-flow"
|
||||
/>
|
||||
|
||||
@@ -25,39 +25,83 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.warningBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 1.125rem;
|
||||
background-color: hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.06);
|
||||
border: 0.125rem solid hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.25);
|
||||
border-radius: 0.625rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
.warningTitle {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.warningDescription {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.codeInputSection {
|
||||
margin-bottom: 0.75rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.codeInput {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.125rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.code {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 0.0625rem solid var(--background-modifier-accent);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--background-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.matchCheck {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
margin: 0.25rem 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.accountLine {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem 0.5rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wrongAccount {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 0.0625rem solid hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.25);
|
||||
border-radius: 0.5rem;
|
||||
background-color: hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.06);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.textButton {
|
||||
padding: 0.125rem 0.25rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: none;
|
||||
color: var(--text-link);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.textButton:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.deviceCard {
|
||||
@@ -129,3 +173,7 @@
|
||||
.successIcon {
|
||||
color: var(--status-online);
|
||||
}
|
||||
|
||||
.deniedIcon {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import type {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/AuthLoginStepTypes';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/AuthLoginStepTypes';
|
||||
import type {DesktopHandoffFlow} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import styles from '@app/features/auth/flow/HandoffApprovalFlow.module.css';
|
||||
import {
|
||||
CANCEL_DESCRIPTOR,
|
||||
@@ -12,6 +13,7 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {
|
||||
formatDesktopHandoffCode,
|
||||
isDesktopHandoffCode,
|
||||
@@ -19,13 +21,18 @@ import {
|
||||
} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CheckCircleIcon, ShieldWarningIcon} from '@phosphor-icons/react';
|
||||
import {CheckCircleIcon, ProhibitIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {useCallback, useState} from 'react';
|
||||
|
||||
const SIGN_IN_CODE_DESCRIPTOR = msg({
|
||||
message: 'Sign-in code',
|
||||
comment: 'Short label in the authentication handoff approval flow. Keep the tone plain and specific.',
|
||||
const APP_CODE_LABEL_DESCRIPTOR = msg({
|
||||
message: 'Code from the {productName} app',
|
||||
comment:
|
||||
'Label of the field in the browser where the user types the 12-character code shown in the desktop or mobile app. productName is the app name, such as Fluxer.',
|
||||
});
|
||||
const SIGN_IN_DESCRIPTOR = msg({
|
||||
message: 'Sign in',
|
||||
comment: 'Button in the browser that approves signing in to the desktop or mobile app that asked for it.',
|
||||
});
|
||||
|
||||
function formatLocation(location: {
|
||||
@@ -37,85 +44,159 @@ function formatLocation(location: {
|
||||
return parts.length > 0 ? parts.join(', ') : null;
|
||||
}
|
||||
|
||||
interface HandoffApprovalFlowProps {
|
||||
mode: DesktopHandoffMode;
|
||||
error: string | null;
|
||||
clientInfo: DesktopHandoffInfoResponse['client_info'];
|
||||
onProceedToCodeInput: () => void;
|
||||
onSubmitCode: (code: string) => void;
|
||||
onApprove: () => void;
|
||||
onDeny: () => void;
|
||||
onRetry: () => void;
|
||||
function HandoffCode({code}: {code: string}) {
|
||||
return (
|
||||
<span className={styles.code} data-flx="auth.flow.handoff-approval-flow.code">
|
||||
{formatDesktopHandoffCode(code)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function HandoffApprovalFlow({
|
||||
mode,
|
||||
error,
|
||||
clientInfo,
|
||||
onProceedToCodeInput,
|
||||
onSubmitCode,
|
||||
onApprove,
|
||||
onDeny,
|
||||
onRetry,
|
||||
}: HandoffApprovalFlowProps) {
|
||||
function ApprovalTitle({handoff}: {handoff: DesktopHandoffFlow}) {
|
||||
if (handoff.returnMethod === 'deep_link' || handoff.clientInfo?.device === 'desktop') {
|
||||
return (
|
||||
<Trans comment="Title of the browser page where the user approves signing in to the desktop app that opened it. PRODUCT_NAME is the app name, such as Fluxer.">
|
||||
Sign in to the {PRODUCT_NAME} desktop app
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
if (handoff.clientInfo?.device === 'mobile') {
|
||||
return (
|
||||
<Trans comment="Title of the browser page where the user approves signing in to the mobile app that asked for it. PRODUCT_NAME is the app name, such as Fluxer.">
|
||||
Sign in to the {PRODUCT_NAME} mobile app
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Trans comment="Title of the browser page where the user approves signing in to an app that asked for it. PRODUCT_NAME is the app name, such as Fluxer.">
|
||||
Sign in to the {PRODUCT_NAME} app
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceCard({clientInfo}: {clientInfo: DesktopHandoffFlow['clientInfo']}) {
|
||||
const platform = clientInfo?.platform ?? null;
|
||||
const os = clientInfo?.os ?? null;
|
||||
const location = clientInfo?.location ? formatLocation(clientInfo.location) : null;
|
||||
if (!platform && !os && !location) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={styles.deviceCard} data-flx="auth.flow.handoff-approval-flow.device-card">
|
||||
{platform ? (
|
||||
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row">
|
||||
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label">
|
||||
<Trans>Platform</Trans>
|
||||
</span>
|
||||
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value">
|
||||
{platform}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{os ? (
|
||||
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row--2">
|
||||
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label--2">
|
||||
<Trans>Operating system</Trans>
|
||||
</span>
|
||||
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value--2">
|
||||
{os}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{location ? (
|
||||
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row--3">
|
||||
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label--3">
|
||||
<Trans>Location</Trans>
|
||||
</span>
|
||||
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value--3">
|
||||
{location}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountLine({handoff, onUseAnotherAccount}: {handoff: DesktopHandoffFlow; onUseAnotherAccount: () => void}) {
|
||||
const account = handoff.account;
|
||||
if (account == null) {
|
||||
return null;
|
||||
}
|
||||
const name = account.globalName ?? account.username;
|
||||
const requestedIdentifier = handoff.requestedIdentifier;
|
||||
return (
|
||||
<div className={styles.accountLine} data-flx="auth.flow.handoff-approval-flow.account-line">
|
||||
{requestedIdentifier != null ? (
|
||||
<p className={styles.wrongAccount} role="alert" data-flx="auth.flow.handoff-approval-flow.wrong-account">
|
||||
<Trans comment="Warning on the browser approval page when the app asked for a different account than the one chosen. requestedIdentifier is the email or username the app asked for, name is the chosen account's display name.">
|
||||
The {PRODUCT_NAME} app asked to sign in as {requestedIdentifier}, but you chose {name}.
|
||||
</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<span data-flx="auth.flow.handoff-approval-flow.account-name">
|
||||
<Trans comment="Shown on the browser approval page above the buttons. name is the display name of the account that will be signed in to the app.">
|
||||
Signing in as {name}
|
||||
</Trans>
|
||||
</span>
|
||||
)}
|
||||
<FocusRing offset={-2} data-flx="auth.flow.handoff-approval-flow.focus-ring.use-another-account">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.textButton}
|
||||
onClick={onUseAnotherAccount}
|
||||
data-flx="auth.flow.handoff-approval-flow.button.use-another-account"
|
||||
>
|
||||
<Trans comment="Text button on the browser approval page that goes back to choosing which account signs in to the app.">
|
||||
Use a different account
|
||||
</Trans>
|
||||
</button>
|
||||
</FocusRing>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HandoffApprovalFlowProps {
|
||||
handoff: DesktopHandoffFlow;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function HandoffApprovalFlow({handoff, onRetry}: HandoffApprovalFlowProps) {
|
||||
const {i18n} = useLingui();
|
||||
const [codeInput, setCodeInput] = useState('');
|
||||
const retry = onRetry ?? handoff.retry;
|
||||
const {submitCode} = handoff;
|
||||
const handleCodeChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawCode = parseDesktopHandoffCodeInput(e.target.value);
|
||||
setCodeInput(rawCode);
|
||||
if (isDesktopHandoffCode(rawCode)) {
|
||||
onSubmitCode(rawCode);
|
||||
void submitCode(rawCode);
|
||||
}
|
||||
},
|
||||
[onSubmitCode],
|
||||
[submitCode],
|
||||
);
|
||||
if (mode === 'warning') {
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container">
|
||||
<div className={styles.warningBox} data-flx="auth.flow.handoff-approval-flow.warning-box">
|
||||
<ShieldWarningIcon
|
||||
size={remFromPx(32)}
|
||||
weight="fill"
|
||||
className={styles.warningIcon}
|
||||
data-flx="auth.flow.handoff-approval-flow.warning-icon"
|
||||
/>
|
||||
<h1 className={styles.warningTitle} data-flx="auth.flow.handoff-approval-flow.warning-title">
|
||||
<Trans>Sign-in request</Trans>
|
||||
</h1>
|
||||
<p className={styles.warningDescription} data-flx="auth.flow.handoff-approval-flow.warning-description">
|
||||
<Trans>
|
||||
You are about to authorize an app to sign in to your account. Only continue if you initiated this request
|
||||
from a device you control.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onProceedToCodeInput}
|
||||
variant="primary"
|
||||
fitContainer
|
||||
data-flx="auth.flow.handoff-approval-flow.button.proceed-to-code-input"
|
||||
>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mode === 'code_input') {
|
||||
if (handoff.mode === DesktopHandoffMode.CODE_INPUT) {
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--2">
|
||||
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title">
|
||||
<Trans>Enter the code</Trans>
|
||||
<Trans comment="Title of the browser page where the user types the 12-character code the app shows under Signing in on another device. PRODUCT_NAME is the app name, such as Fluxer.">
|
||||
Enter the code from the {PRODUCT_NAME} app
|
||||
</Trans>
|
||||
</h1>
|
||||
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description">
|
||||
<Trans>Enter the code shown in the app to continue.</Trans>
|
||||
<Trans comment="Explains where the code comes from, under the title of the browser page where it is typed. Signing in on another device is the label of a button in the app and must match its translation. PRODUCT_NAME is the app name, such as Fluxer.">
|
||||
The {PRODUCT_NAME} app on your other device shows it under Signing in on another device. Only enter a code
|
||||
from a device you control.
|
||||
</Trans>
|
||||
</p>
|
||||
<div className={styles.codeInputSection} data-flx="auth.flow.handoff-approval-flow.code-input-section">
|
||||
<Input
|
||||
aria-label={i18n._(SIGN_IN_CODE_DESCRIPTOR)}
|
||||
label={i18n._(APP_CODE_LABEL_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
name="desktop_handoff_code"
|
||||
className={styles.codeInput}
|
||||
value={formatDesktopHandoffCode(codeInput)}
|
||||
onChange={handleCodeChange}
|
||||
placeholder="XXXXXX-XXXXXX"
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
autoFocus
|
||||
@@ -125,7 +206,7 @@ export function HandoffApprovalFlow({
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRetry}
|
||||
onClick={retry}
|
||||
variant="secondary"
|
||||
fitContainer
|
||||
data-flx="auth.flow.handoff-approval-flow.button.cancel"
|
||||
@@ -135,11 +216,17 @@ export function HandoffApprovalFlow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mode === 'fetching_info') {
|
||||
if (handoff.mode === DesktopHandoffMode.FETCHING_INFO || handoff.mode === DesktopHandoffMode.COMPLETING) {
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--3">
|
||||
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--2">
|
||||
<Trans>Verifying code…</Trans>
|
||||
{handoff.mode === DesktopHandoffMode.FETCHING_INFO ? (
|
||||
<Trans comment="Title while the browser looks up the sign-in request from the app.">
|
||||
Checking the sign-in request…
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Title while the browser approves the sign-in to the app.">Signing in…</Trans>
|
||||
)}
|
||||
</h1>
|
||||
<div className={styles.spinner} data-flx="auth.flow.handoff-approval-flow.spinner">
|
||||
<Spinner data-flx="auth.flow.handoff-approval-flow.spinner-icon" />
|
||||
@@ -147,81 +234,42 @@ export function HandoffApprovalFlow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mode === 'approving') {
|
||||
const platform = clientInfo?.platform ?? null;
|
||||
const os = clientInfo?.os ?? null;
|
||||
const location = clientInfo?.location ? formatLocation(clientInfo.location) : null;
|
||||
const hasAnyDeviceInfo = Boolean(platform || os || location);
|
||||
if (handoff.mode === DesktopHandoffMode.APPROVING) {
|
||||
const matchCode = handoff.codeSource === 'link' && handoff.returnMethod === 'code' ? handoff.handoffCode : null;
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--4">
|
||||
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--3">
|
||||
<Trans>Was this you?</Trans>
|
||||
<ApprovalTitle handoff={handoff} />
|
||||
</h1>
|
||||
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description--2">
|
||||
{hasAnyDeviceInfo ? (
|
||||
<Trans>A sign-in request was made from the following device. Only approve if you recognize it.</Trans>
|
||||
) : (
|
||||
<Trans>A sign-in request was made for your account. Only approve if you initiated it.</Trans>
|
||||
)}
|
||||
<Trans comment="Warning under the title of the browser approval page, so people do not approve a sign-in someone else started.">
|
||||
Only continue if you started this sign-in on a device you control.
|
||||
</Trans>
|
||||
</p>
|
||||
{hasAnyDeviceInfo ? (
|
||||
<div className={styles.deviceCard} data-flx="auth.flow.handoff-approval-flow.device-card">
|
||||
{platform ? (
|
||||
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row">
|
||||
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label">
|
||||
<Trans>Platform</Trans>
|
||||
</span>
|
||||
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value">
|
||||
{platform}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{os ? (
|
||||
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row--2">
|
||||
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label--2">
|
||||
<Trans>Operating system</Trans>
|
||||
</span>
|
||||
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value--2">
|
||||
{os}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{location ? (
|
||||
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row--3">
|
||||
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label--3">
|
||||
<Trans>Location</Trans>
|
||||
</span>
|
||||
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value--3">
|
||||
{location}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<AccountLine handoff={handoff} onUseAnotherAccount={retry} />
|
||||
{matchCode != null ? (
|
||||
<div className={styles.matchCheck} data-flx="auth.flow.handoff-approval-flow.match-check">
|
||||
<span data-flx="auth.flow.handoff-approval-flow.match-check-label">
|
||||
<Trans comment="Shown above the code on the browser approval page when the app cannot be signed in automatically. PRODUCT_NAME is the app name, such as Fluxer.">
|
||||
Make sure this code matches the one in the {PRODUCT_NAME} app.
|
||||
</Trans>
|
||||
</span>
|
||||
<HandoffCode code={matchCode} />
|
||||
</div>
|
||||
) : null}
|
||||
<DeviceCard clientInfo={handoff.clientInfo} />
|
||||
<div className={styles.buttonRow} data-flx="auth.flow.handoff-approval-flow.button-row">
|
||||
<Button onClick={onDeny} variant="secondary" data-flx="auth.flow.handoff-approval-flow.button.deny">
|
||||
<Trans>Deny</Trans>
|
||||
<Button onClick={handoff.deny} variant="secondary" data-flx="auth.flow.handoff-approval-flow.button.deny">
|
||||
{i18n._(CANCEL_DESCRIPTOR)}
|
||||
</Button>
|
||||
<Button onClick={onApprove} variant="primary" data-flx="auth.flow.handoff-approval-flow.button.approve">
|
||||
<Trans>Approve</Trans>
|
||||
<Button onClick={handoff.approve} variant="primary" data-flx="auth.flow.handoff-approval-flow.button.approve">
|
||||
{i18n._(SIGN_IN_DESCRIPTOR)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mode === 'completing') {
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--5">
|
||||
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--4">
|
||||
<Trans>Completing sign-in…</Trans>
|
||||
</h1>
|
||||
<div className={styles.spinner} data-flx="auth.flow.handoff-approval-flow.spinner--2">
|
||||
<Spinner data-flx="auth.flow.handoff-approval-flow.spinner-icon--2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mode === 'done') {
|
||||
if (handoff.mode === DesktopHandoffMode.DONE) {
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--6">
|
||||
<CheckCircleIcon
|
||||
@@ -231,26 +279,80 @@ export function HandoffApprovalFlow({
|
||||
data-flx="auth.flow.handoff-approval-flow.success-icon"
|
||||
/>
|
||||
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--5">
|
||||
<Trans>Sign-in approved</Trans>
|
||||
<Trans comment="Title of the browser page after the user approved signing in to the app.">
|
||||
You're signed in
|
||||
</Trans>
|
||||
</h1>
|
||||
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description--3">
|
||||
<Trans>The app should now be signed in to your account.</Trans>
|
||||
{handoff.returnUrl != null ? (
|
||||
<Trans comment="Shown after approving, when the browser has already handed the sign-in back to the desktop app. PRODUCT_NAME is the app name, such as Fluxer.">
|
||||
Go back to the {PRODUCT_NAME} app. You can close this tab.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Shown after approving a sign-in for an app on another device, which signs in by itself shortly. PRODUCT_NAME is the app name, such as Fluxer.">
|
||||
Go back to the {PRODUCT_NAME} app. It finishes signing in on its own.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
{handoff.returnUrl != null ? (
|
||||
<Button
|
||||
onClick={handoff.reopenApp}
|
||||
variant="secondary"
|
||||
fitContainer
|
||||
data-flx="auth.flow.handoff-approval-flow.button.open-app"
|
||||
>
|
||||
<Trans comment="Button on the browser page after approving that opens the desktop app again if it did not come to the front. PRODUCT_NAME is the app name, such as Fluxer.">
|
||||
Open {PRODUCT_NAME}
|
||||
</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mode === 'error') {
|
||||
if (handoff.mode === DesktopHandoffMode.DENIED) {
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--8">
|
||||
<ProhibitIcon
|
||||
size={remFromPx(48)}
|
||||
weight="regular"
|
||||
className={styles.deniedIcon}
|
||||
data-flx="auth.flow.handoff-approval-flow.denied-icon"
|
||||
/>
|
||||
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--7">
|
||||
<Trans comment="Title of the browser page after the user cancelled the sign-in request from the app.">
|
||||
Sign-in cancelled
|
||||
</Trans>
|
||||
</h1>
|
||||
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description--4">
|
||||
<Trans comment="Shown after the user cancelled the sign-in request in the browser. PRODUCT_NAME is the app name, such as Fluxer.">
|
||||
The {PRODUCT_NAME} app was not signed in. You can close this tab.
|
||||
</Trans>
|
||||
</p>
|
||||
<Button
|
||||
onClick={retry}
|
||||
variant="secondary"
|
||||
fitContainer
|
||||
data-flx="auth.flow.handoff-approval-flow.button.restart"
|
||||
>
|
||||
<Trans comment="Button on the browser page after cancelling that goes back to choosing an account.">
|
||||
Start over
|
||||
</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (handoff.mode === DesktopHandoffMode.ERROR) {
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--7">
|
||||
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--6">
|
||||
{i18n._(SOMETHING_WENT_WRONG_DESCRIPTOR)}
|
||||
</h1>
|
||||
{error ? (
|
||||
{handoff.error ? (
|
||||
<p className={styles.error} role="alert" data-flx="auth.flow.handoff-approval-flow.error">
|
||||
{error}
|
||||
{handoff.error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button onClick={onRetry} fitContainer data-flx="auth.flow.handoff-approval-flow.button.retry">
|
||||
<Button onClick={retry} fitContainer data-flx="auth.flow.handoff-approval-flow.button.retry">
|
||||
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -258,32 +360,3 @@ export function HandoffApprovalFlow({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ConnectedHandoffApprovalFlowProps {
|
||||
handoff: {
|
||||
mode: DesktopHandoffMode;
|
||||
error: string | null;
|
||||
clientInfo: DesktopHandoffInfoResponse['client_info'];
|
||||
proceedToCodeInput: () => void;
|
||||
submitCode: (code: string) => void;
|
||||
approve: () => void;
|
||||
deny: () => void;
|
||||
retry: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function ConnectedHandoffApprovalFlow({handoff}: ConnectedHandoffApprovalFlowProps) {
|
||||
return (
|
||||
<HandoffApprovalFlow
|
||||
mode={handoff.mode}
|
||||
error={handoff.error}
|
||||
clientInfo={handoff.clientInfo}
|
||||
onProceedToCodeInput={handoff.proceedToCodeInput}
|
||||
onSubmitCode={handoff.submitCode}
|
||||
onApprove={handoff.approve}
|
||||
onDeny={handoff.deny}
|
||||
onRetry={handoff.retry}
|
||||
data-flx="auth.flow.handoff-approval-flow.connected-handoff-approval-flow.handoff-approval-flow"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,6 +162,14 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.instanceLogo img {
|
||||
display: block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.instanceMeta {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.browserInstanceLogo img {
|
||||
display: block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.browserInstanceMeta {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
@@ -100,6 +108,13 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.browserStatusTitle {
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.browserStatusText {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
@@ -116,7 +131,7 @@
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.browserManualCode {
|
||||
.browserCode {
|
||||
max-width: 100%;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border: 0.0625rem solid var(--background-modifier-accent);
|
||||
@@ -140,10 +155,25 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.browserTimerExpired {
|
||||
color: var(--status-danger);
|
||||
font-size: 0.8125rem;
|
||||
.browserTextButton {
|
||||
padding: 0.125rem 0.25rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: none;
|
||||
color: var(--text-link);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browserTextButton:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.browserTextButton:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.browserFooter {
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import {resolveSnapshotInstanceDomain} from '@app/features/auth/AccountDisplayUtils';
|
||||
import {InstanceBrandMark} from '@app/features/auth/components/InstanceBrandMark';
|
||||
import loginStyles from '@app/features/auth/components/pages/LoginPage.module.css';
|
||||
import styles from '@app/features/auth/flow/auth_login_core/AuthLoginBrowserStep.module.css';
|
||||
import {
|
||||
DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR,
|
||||
DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR,
|
||||
} from '@app/features/auth/flow/browser_handoff/BrowserHandoffDescriptors';
|
||||
import {
|
||||
BrowserLoginHandoffAction,
|
||||
type BrowserLoginHandoffPendingAction,
|
||||
type BrowserLoginHandoffController,
|
||||
useBrowserLoginHandoff,
|
||||
} from '@app/features/auth/flow/browser_handoff/useBrowserLoginHandoff';
|
||||
import {
|
||||
@@ -19,9 +16,11 @@ import {
|
||||
resolveInstanceLabel,
|
||||
} from '@app/features/auth/flow/instance_selector/InstanceDirectoryStorage';
|
||||
import {useKnownInstances} from '@app/features/auth/flow/instance_selector/useKnownInstances';
|
||||
import {resolveInstanceBrandIconUrl} from '@app/features/auth/InstanceBranding';
|
||||
import type {LoginSuccessPayload} from '@app/features/auth/state/AuthFlow';
|
||||
import {
|
||||
BACK_DESCRIPTOR,
|
||||
CANCEL_DESCRIPTOR,
|
||||
CHANGE_INSTANCE_DESCRIPTOR,
|
||||
COPIED_DESCRIPTOR,
|
||||
COPY_CODE_DESCRIPTOR,
|
||||
@@ -32,12 +31,12 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button, ButtonVariant} from '@app/features/ui/button/Button';
|
||||
import {VerifiedConnectionIcon} from '@app/features/ui/components/icons/VerifiedConnectionIcon';
|
||||
import {Spinner, SpinnerSize} from '@app/features/ui/components/Spinner';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {SteppedCarousel, SteppedCarouselActions} from '@app/features/ui/stepped_carousel/SteppedCarousel';
|
||||
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import FluxerLogoAsset from '@app/media/images/fluxer-logo-color.svg?react';
|
||||
import {DesktopHandoffReturnMethod} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
|
||||
import {isOfficialInstanceHost, OFFICIAL_INSTANCE_NAME} from '@fluxer/instance_bootstrap/src/OfficialInstance';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {
|
||||
@@ -46,28 +45,14 @@ import {
|
||||
ArrowSquareOutIcon,
|
||||
CheckCircleIcon,
|
||||
ClipboardIcon,
|
||||
GlobeIcon,
|
||||
PasswordIcon,
|
||||
WarningCircleIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {type ReactElement, type ReactNode, useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const OPEN_BROWSER_DESCRIPTOR = msg({
|
||||
message: 'Open browser',
|
||||
comment: 'Button label that opens the browser sign-in handoff page.',
|
||||
});
|
||||
const SHOW_CODE_DESCRIPTOR = msg({
|
||||
message: 'Show code',
|
||||
comment: 'Button label that opens the manual browser handoff code fallback.',
|
||||
});
|
||||
const GENERATE_NEW_CODE_DESCRIPTOR = msg({
|
||||
message: 'Generate new code',
|
||||
comment: 'Button label that replaces an expired browser sign-in code with a fresh one.',
|
||||
});
|
||||
const BROWSER_CODE_EXPIRED_DESCRIPTOR = msg({
|
||||
message: 'This code expired.',
|
||||
comment: 'Status shown under the browser sign-in code once its countdown reaches zero.',
|
||||
const OPEN_BROWSER_AGAIN_DESCRIPTOR = msg({
|
||||
message: 'Open browser again',
|
||||
comment: 'Button in the desktop app that reopens the browser sign-in page while the app waits for the browser.',
|
||||
});
|
||||
const BROWSER_WAIT_STOPPED_DESCRIPTOR = msg({
|
||||
message: "Fluxer stopped checking whether the browser approved this sign-in. It didn't lose your code.",
|
||||
@@ -78,6 +63,10 @@ const BROWSER_HANDOFF_UNSUPPORTED_INSTANCE_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Explanation shown when the selected instance advertises no web app endpoint. Instance name is interpolated.',
|
||||
});
|
||||
const BROWSER_HANDOFF_FAILED_DESCRIPTOR = msg({
|
||||
message: "Couldn't start signing in with your browser.",
|
||||
comment: 'Shown in the desktop app when the browser sign-in request could not be created.',
|
||||
});
|
||||
|
||||
const logger = new Logger('AuthLoginBrowserStep');
|
||||
|
||||
@@ -86,25 +75,26 @@ export function resolveShouldOfferBrowserStep(): boolean {
|
||||
}
|
||||
|
||||
export const BrowserHandoffStep = Object.freeze({
|
||||
READY: 'ready',
|
||||
WAITING: 'waiting',
|
||||
MANUAL: 'manual',
|
||||
OTHER_DEVICE: 'other_device',
|
||||
PROBLEM: 'problem',
|
||||
} as const);
|
||||
|
||||
export type BrowserHandoffStep = (typeof BrowserHandoffStep)[keyof typeof BrowserHandoffStep];
|
||||
|
||||
const BROWSER_STEPS: ReadonlyArray<BrowserHandoffStep> = Object.freeze([
|
||||
BrowserHandoffStep.READY,
|
||||
BrowserHandoffStep.WAITING,
|
||||
BrowserHandoffStep.MANUAL,
|
||||
BrowserHandoffStep.OTHER_DEVICE,
|
||||
BrowserHandoffStep.PROBLEM,
|
||||
]);
|
||||
|
||||
type ManualReturnStep = typeof BrowserHandoffStep.READY | typeof BrowserHandoffStep.WAITING;
|
||||
type RetryStep = typeof BrowserHandoffStep.WAITING | typeof BrowserHandoffStep.OTHER_DEVICE;
|
||||
|
||||
interface BrowserInstanceIdentity {
|
||||
readonly domain: string;
|
||||
readonly isOfficial: boolean;
|
||||
readonly name: string;
|
||||
readonly iconUrl: string | null;
|
||||
}
|
||||
|
||||
function findKnownInstanceName(domain: string, knownInstances: ReadonlyArray<InstanceInfo>): string | null {
|
||||
@@ -129,340 +119,194 @@ function resolveBrowserInstanceIdentity({
|
||||
const domain = resolveSnapshotInstanceDomain(snapshot) ?? apiEndpoint;
|
||||
const isOfficial = isOfficialInstanceHost(apiEndpoint);
|
||||
if (isOfficial) {
|
||||
return {domain, isOfficial, name: OFFICIAL_INSTANCE_NAME};
|
||||
return {domain, isOfficial, name: OFFICIAL_INSTANCE_NAME, iconUrl: null};
|
||||
}
|
||||
const iconUrl = resolveInstanceBrandIconUrl(snapshot);
|
||||
const productLabel = resolveInstanceLabel(snapshot.appPublic?.branding?.product_name, domain);
|
||||
if (productLabel !== instanceDomainHost(domain)) {
|
||||
return {domain, isOfficial, name: productLabel};
|
||||
return {domain, isOfficial, name: productLabel, iconUrl};
|
||||
}
|
||||
return {domain, isOfficial, name: resolveInstanceLabel(findKnownInstanceName(domain, knownInstances), domain)};
|
||||
}
|
||||
|
||||
function resolveManualReturnStep(step: BrowserHandoffStep): ManualReturnStep {
|
||||
if (step === BrowserHandoffStep.WAITING) {
|
||||
return BrowserHandoffStep.WAITING;
|
||||
}
|
||||
return BrowserHandoffStep.READY;
|
||||
}
|
||||
|
||||
function renderCopyCodeIcon(copied: boolean): ReactNode {
|
||||
if (copied) {
|
||||
return (
|
||||
<CheckCircleIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.render-copy-code-icon.check-circle-icon"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ClipboardIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.render-copy-code-icon.clipboard-icon"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCopyCodeLabel(i18n: I18n, copied: boolean): string {
|
||||
if (copied) {
|
||||
return i18n._(COPIED_DESCRIPTOR);
|
||||
}
|
||||
return i18n._(COPY_CODE_DESCRIPTOR);
|
||||
}
|
||||
|
||||
interface BrowserStepActionsProps {
|
||||
readonly activeBackAction: () => void;
|
||||
readonly canStart: boolean;
|
||||
readonly copied: boolean;
|
||||
readonly copyCode: () => void;
|
||||
readonly hasCode: boolean;
|
||||
readonly hasStoppedWaiting: boolean;
|
||||
readonly isExpired: boolean;
|
||||
readonly isGenerating: boolean;
|
||||
readonly onOpenBrowser: () => void;
|
||||
readonly onRegenerateCode: () => void;
|
||||
readonly onResumeWaiting: () => void;
|
||||
readonly onShowCode: () => void;
|
||||
readonly pendingAction: BrowserLoginHandoffPendingAction;
|
||||
readonly showBackButton: boolean;
|
||||
readonly step: BrowserHandoffStep;
|
||||
}
|
||||
|
||||
function BrowserStepActions({
|
||||
activeBackAction,
|
||||
canStart,
|
||||
copied,
|
||||
copyCode,
|
||||
hasCode,
|
||||
hasStoppedWaiting,
|
||||
isExpired,
|
||||
isGenerating,
|
||||
onOpenBrowser,
|
||||
onRegenerateCode,
|
||||
onResumeWaiting,
|
||||
onShowCode,
|
||||
pendingAction,
|
||||
showBackButton,
|
||||
step,
|
||||
}: BrowserStepActionsProps): ReactElement {
|
||||
const {i18n} = useLingui();
|
||||
const handoffButtonState = (action: BrowserLoginHandoffAction): {disabled: boolean; submitting: boolean} => ({
|
||||
disabled: !canStart || (isGenerating && pendingAction !== action),
|
||||
submitting: isGenerating && pendingAction === action,
|
||||
});
|
||||
const regenerateButton = (
|
||||
<Button
|
||||
onClick={onRegenerateCode}
|
||||
{...handoffButtonState(BrowserLoginHandoffAction.SHOW_CODE)}
|
||||
leftIcon={
|
||||
<ArrowClockwiseIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.arrow-clockwise-icon"
|
||||
/>
|
||||
}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.generate-new-code"
|
||||
>
|
||||
{i18n._(GENERATE_NEW_CODE_DESCRIPTOR)}
|
||||
</Button>
|
||||
);
|
||||
const showCodeButton = (
|
||||
<Button
|
||||
variant={ButtonVariant.SECONDARY}
|
||||
onClick={onShowCode}
|
||||
{...handoffButtonState(BrowserLoginHandoffAction.SHOW_CODE)}
|
||||
leftIcon={
|
||||
<PasswordIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.password-icon"
|
||||
/>
|
||||
}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.show-code"
|
||||
>
|
||||
{i18n._(SHOW_CODE_DESCRIPTOR)}
|
||||
</Button>
|
||||
);
|
||||
const resumeButton = hasStoppedWaiting ? (
|
||||
<Button
|
||||
onClick={onResumeWaiting}
|
||||
leftIcon={
|
||||
<ArrowClockwiseIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.resume-icon"
|
||||
/>
|
||||
}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.resume-waiting"
|
||||
>
|
||||
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : null;
|
||||
let primaryActions: ReactNode;
|
||||
if (step === BrowserHandoffStep.MANUAL) {
|
||||
primaryActions = (
|
||||
<>
|
||||
{isExpired ? (
|
||||
regenerateButton
|
||||
) : (
|
||||
<Button
|
||||
onClick={copyCode}
|
||||
disabled={!hasCode}
|
||||
leftIcon={renderCopyCodeIcon(copied)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.copy-code"
|
||||
>
|
||||
{resolveCopyCodeLabel(i18n, copied)}
|
||||
</Button>
|
||||
)}
|
||||
{resumeButton}
|
||||
</>
|
||||
);
|
||||
} else if (step === BrowserHandoffStep.WAITING) {
|
||||
primaryActions = (
|
||||
<>
|
||||
{showCodeButton}
|
||||
{resumeButton}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
primaryActions = (
|
||||
<>
|
||||
{showCodeButton}
|
||||
<Button
|
||||
onClick={onOpenBrowser}
|
||||
{...handoffButtonState(BrowserLoginHandoffAction.OPEN_BROWSER)}
|
||||
leftIcon={
|
||||
<ArrowSquareOutIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.arrow-square-out-icon"
|
||||
/>
|
||||
}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.open-browser"
|
||||
>
|
||||
{i18n._(OPEN_BROWSER_DESCRIPTOR)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const renderBackAction = (): ReactNode => {
|
||||
if (!showBackButton) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={ButtonVariant.SECONDARY}
|
||||
leftIcon={
|
||||
<ArrowLeftIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.render-back-action.arrow-left-icon"
|
||||
/>
|
||||
}
|
||||
onClick={activeBackAction}
|
||||
disabled={isGenerating}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.back"
|
||||
>
|
||||
{i18n._(BACK_DESCRIPTOR)}
|
||||
</Button>
|
||||
);
|
||||
return {
|
||||
domain,
|
||||
isOfficial,
|
||||
name: resolveInstanceLabel(findKnownInstanceName(domain, knownInstances), domain),
|
||||
iconUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSignInAddress(webAppEndpoint: string): string {
|
||||
try {
|
||||
const url = new URL(webAppEndpoint);
|
||||
return `${url.host}${url.pathname.replace(/\/+$/u, '')}/login?handoff=1`;
|
||||
} catch {
|
||||
return `${webAppEndpoint}/login?handoff=1`;
|
||||
}
|
||||
}
|
||||
|
||||
function HandoffCode({code}: {code: string}): ReactElement {
|
||||
return (
|
||||
<SteppedCarouselActions
|
||||
className={styles.browserFooter}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.browser-footer"
|
||||
<flx-auth-login-browser-step-code
|
||||
className={flxElementClassName(styles.browserCode)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.code"
|
||||
>
|
||||
{renderBackAction()}
|
||||
{primaryActions}
|
||||
</SteppedCarouselActions>
|
||||
{code}
|
||||
</flx-auth-login-browser-step-code>
|
||||
);
|
||||
}
|
||||
|
||||
interface BrowserStepContentProps {
|
||||
readonly canStart: boolean;
|
||||
readonly displayCode: string;
|
||||
readonly hasStoppedWaiting: boolean;
|
||||
interface WaitingContentProps {
|
||||
readonly handoff: BrowserLoginHandoffController;
|
||||
readonly instanceName: string;
|
||||
readonly isExpired: boolean;
|
||||
readonly remainingSeconds: number | null;
|
||||
readonly step: BrowserHandoffStep;
|
||||
readonly onOtherDevice: () => void;
|
||||
}
|
||||
|
||||
function renderManualCodeTimer(i18n: I18n, isExpired: boolean, remainingSeconds: number | null): ReactNode {
|
||||
if (remainingSeconds == null) {
|
||||
return null;
|
||||
}
|
||||
if (isExpired) {
|
||||
return (
|
||||
<flx-auth-login-browser-step-timer
|
||||
className={flxElementClassName(styles.browserTimerExpired)}
|
||||
role="status"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-timer-expired"
|
||||
>
|
||||
{i18n._(BROWSER_CODE_EXPIRED_DESCRIPTOR)}
|
||||
</flx-auth-login-browser-step-timer>
|
||||
);
|
||||
}
|
||||
function WaitingContent({handoff, instanceName, onOtherDevice}: WaitingContentProps): ReactElement {
|
||||
const {i18n} = useLingui();
|
||||
const showCode = handoff.returnMethod === DesktopHandoffReturnMethod.CODE && handoff.hasCode;
|
||||
return (
|
||||
<flx-auth-login-browser-step-timer
|
||||
className={flxElementClassName(styles.browserTimer)}
|
||||
aria-live="off"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-timer"
|
||||
<flx-auth-login-browser-step-status-panel
|
||||
className={flxElementClassName(styles.browserStatusPanel)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-panel"
|
||||
>
|
||||
<Trans>Expires in {remainingSeconds}s</Trans>
|
||||
</flx-auth-login-browser-step-timer>
|
||||
{handoff.hasStoppedWaiting ? (
|
||||
<WarningCircleIcon
|
||||
size={remFromPx(28)}
|
||||
weight="regular"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.stopped-waiting-icon"
|
||||
/>
|
||||
) : (
|
||||
<Spinner size={SpinnerSize.MEDIUM} data-flx="auth.flow.auth-login-core.auth-login-browser-step.spinner" />
|
||||
)}
|
||||
<flx-auth-login-browser-step-status-title
|
||||
className={flxElementClassName(styles.browserStatusTitle)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-title"
|
||||
>
|
||||
<Trans comment="Title in the desktop app while it waits for the user to sign in on the browser page it opened.">
|
||||
Continue in your browser
|
||||
</Trans>
|
||||
</flx-auth-login-browser-step-status-title>
|
||||
<flx-auth-login-browser-step-status-text
|
||||
className={flxElementClassName(styles.browserStatusText)}
|
||||
role="status"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-text"
|
||||
>
|
||||
{handoff.hasStoppedWaiting ? (
|
||||
i18n._(BROWSER_WAIT_STOPPED_DESCRIPTOR)
|
||||
) : showCode ? (
|
||||
<Trans comment="Shown in the desktop app above the code when the browser cannot return the sign-in automatically. instanceName is the server name, such as Fluxer.">
|
||||
Sign in to {instanceName} in your browser, and check that it shows this code:
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Shown in the desktop app while it waits for the browser. instanceName is the server name, such as Fluxer.">
|
||||
Sign in to {instanceName} in your browser and confirm. This app finishes signing in on its own.
|
||||
</Trans>
|
||||
)}
|
||||
</flx-auth-login-browser-step-status-text>
|
||||
{showCode && !handoff.hasStoppedWaiting ? <HandoffCode code={handoff.displayCode} /> : null}
|
||||
<FocusRing offset={-2} data-flx="auth.flow.auth-login-core.auth-login-browser-step.focus-ring.other-device">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.browserTextButton}
|
||||
onClick={onOtherDevice}
|
||||
disabled={!handoff.canStart}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.other-device"
|
||||
>
|
||||
<Trans comment="Text button in the desktop app that shows a code to type into a browser on a different device.">
|
||||
Signing in on another device?
|
||||
</Trans>
|
||||
</button>
|
||||
</FocusRing>
|
||||
</flx-auth-login-browser-step-status-panel>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserStepContent({
|
||||
canStart,
|
||||
displayCode,
|
||||
hasStoppedWaiting,
|
||||
instanceName,
|
||||
isExpired,
|
||||
remainingSeconds,
|
||||
step,
|
||||
}: BrowserStepContentProps): ReactElement {
|
||||
function OtherDeviceContent({handoff, signInAddress}: {handoff: BrowserLoginHandoffController; signInAddress: string}) {
|
||||
const {i18n} = useLingui();
|
||||
if (step === BrowserHandoffStep.READY) {
|
||||
return (
|
||||
<flx-auth-login-browser-step-status-panel
|
||||
className={flxElementClassName(styles.browserStatusPanel)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-panel"
|
||||
>
|
||||
{canStart ? (
|
||||
<ArrowSquareOutIcon
|
||||
size={remFromPx(28)}
|
||||
weight="regular"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.arrow-square-out-icon"
|
||||
/>
|
||||
) : (
|
||||
<WarningCircleIcon
|
||||
size={remFromPx(28)}
|
||||
weight="regular"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.warning-circle-icon"
|
||||
/>
|
||||
)}
|
||||
<flx-auth-login-browser-step-status-text
|
||||
className={flxElementClassName(styles.browserStatusText)}
|
||||
role="status"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-text"
|
||||
>
|
||||
{canStart ? (
|
||||
<Trans>Open your browser to finish signing in.</Trans>
|
||||
) : (
|
||||
i18n._(BROWSER_HANDOFF_UNSUPPORTED_INSTANCE_DESCRIPTOR, {instanceName})
|
||||
)}
|
||||
</flx-auth-login-browser-step-status-text>
|
||||
</flx-auth-login-browser-step-status-panel>
|
||||
);
|
||||
}
|
||||
if (step === BrowserHandoffStep.WAITING) {
|
||||
return (
|
||||
<flx-auth-login-browser-step-status-panel
|
||||
className={flxElementClassName(styles.browserStatusPanel)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-panel--2"
|
||||
>
|
||||
{hasStoppedWaiting ? (
|
||||
<WarningCircleIcon
|
||||
size={remFromPx(28)}
|
||||
weight="regular"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.stopped-waiting-icon"
|
||||
/>
|
||||
) : (
|
||||
<Spinner
|
||||
size={SpinnerSize.MEDIUM}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.spinner"
|
||||
/>
|
||||
)}
|
||||
<flx-auth-login-browser-step-status-text
|
||||
className={flxElementClassName(styles.browserStatusText)}
|
||||
role="status"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-text--2"
|
||||
>
|
||||
{hasStoppedWaiting ? i18n._(BROWSER_WAIT_STOPPED_DESCRIPTOR) : <Trans>Waiting for browser approval.</Trans>}
|
||||
</flx-auth-login-browser-step-status-text>
|
||||
</flx-auth-login-browser-step-status-panel>
|
||||
);
|
||||
}
|
||||
const remainingSeconds = handoff.remainingSeconds;
|
||||
return (
|
||||
<flx-auth-login-browser-step-manual-panel
|
||||
className={flxElementClassName(styles.browserManualPanel)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-manual-panel"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-panel"
|
||||
>
|
||||
<flx-auth-login-browser-step-manual-code
|
||||
className={flxElementClassName(styles.browserManualCode)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-manual-code"
|
||||
<flx-auth-login-browser-step-status-title
|
||||
className={flxElementClassName(styles.browserStatusTitle)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-title"
|
||||
>
|
||||
{displayCode}
|
||||
</flx-auth-login-browser-step-manual-code>
|
||||
{renderManualCodeTimer(i18n, isExpired, remainingSeconds)}
|
||||
<Trans comment="Title in the desktop app above the code the user types into a browser on another device.">
|
||||
Enter this code in your browser
|
||||
</Trans>
|
||||
</flx-auth-login-browser-step-status-title>
|
||||
<flx-auth-login-browser-step-status-text
|
||||
className={flxElementClassName(styles.browserStatusText)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-text"
|
||||
>
|
||||
<Trans comment="One line explanation in the desktop app above the code. signInAddress is the web address of the sign-in page, such as web.fluxer.app/login?handoff=1.">
|
||||
On your other device, sign in at {signInAddress} and enter this code when asked.
|
||||
</Trans>
|
||||
</flx-auth-login-browser-step-status-text>
|
||||
{handoff.hasCode ? (
|
||||
<HandoffCode code={handoff.displayCode} />
|
||||
) : (
|
||||
<Spinner size={SpinnerSize.MEDIUM} data-flx="auth.flow.auth-login-core.auth-login-browser-step.code-spinner" />
|
||||
)}
|
||||
<flx-auth-login-browser-step-timer
|
||||
className={flxElementClassName(styles.browserTimer)}
|
||||
aria-live="off"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.timer"
|
||||
>
|
||||
{remainingSeconds == null ? ' ' : <Trans>Expires in {remainingSeconds}s</Trans>}
|
||||
</flx-auth-login-browser-step-timer>
|
||||
{handoff.hasStoppedWaiting ? (
|
||||
<flx-auth-login-browser-step-status-text
|
||||
className={flxElementClassName(styles.browserStatusText)}
|
||||
role="status"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-stopped"
|
||||
>
|
||||
{i18n._(BROWSER_WAIT_STOPPED_DESCRIPTOR)}
|
||||
</flx-auth-login-browser-step-status-text>
|
||||
) : null}
|
||||
</flx-auth-login-browser-step-manual-panel>
|
||||
);
|
||||
}
|
||||
|
||||
function ProblemContent({message}: {message: string}): ReactElement {
|
||||
return (
|
||||
<flx-auth-login-browser-step-status-panel
|
||||
className={flxElementClassName(styles.browserStatusPanel)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-panel"
|
||||
>
|
||||
<WarningCircleIcon
|
||||
size={remFromPx(28)}
|
||||
weight="regular"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-icon"
|
||||
/>
|
||||
<flx-auth-login-browser-step-status-text
|
||||
className={flxElementClassName(styles.browserStatusText)}
|
||||
role="alert"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-text"
|
||||
>
|
||||
{message}
|
||||
</flx-auth-login-browser-step-status-text>
|
||||
</flx-auth-login-browser-step-status-panel>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonIcon({icon}: {icon: 'open' | 'retry' | 'back' | 'copy' | 'copied'}): ReactElement {
|
||||
const props = {size: remFromPx(16), weight: 'bold' as const};
|
||||
switch (icon) {
|
||||
case 'open':
|
||||
return <ArrowSquareOutIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.open" />;
|
||||
case 'retry':
|
||||
return <ArrowClockwiseIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.retry" />;
|
||||
case 'back':
|
||||
return <ArrowLeftIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.back" />;
|
||||
case 'copy':
|
||||
return <ClipboardIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.copy" />;
|
||||
case 'copied':
|
||||
return <CheckCircleIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.copied" />;
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthLoginBrowserStepProps {
|
||||
readonly extraTopContent?: ReactNode;
|
||||
readonly prefillIdentifier?: string | null;
|
||||
@@ -489,89 +333,86 @@ export const AuthLoginBrowserStep = observer(function AuthLoginBrowserStep({
|
||||
showBackButton = true,
|
||||
}: AuthLoginBrowserStepProps) {
|
||||
const {i18n} = useLingui();
|
||||
const [step, setStep] = useState<BrowserHandoffStep>(BrowserHandoffStep.READY);
|
||||
const [manualReturnStep, setManualReturnStep] = useState<ManualReturnStep>(BrowserHandoffStep.READY);
|
||||
const [step, setStep] = useState<BrowserHandoffStep>(BrowserHandoffStep.WAITING);
|
||||
const [retryStep, setRetryStep] = useState<RetryStep>(BrowserHandoffStep.WAITING);
|
||||
const knownInstances = useKnownInstances();
|
||||
const instanceIdentity = resolveBrowserInstanceIdentity({snapshot: runtimeSnapshot, knownInstances});
|
||||
const handoffMessages = useMemo(
|
||||
() => ({
|
||||
desktopHandoffUnavailable: DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR,
|
||||
expired: DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const handleExpired = useCallback(() => {
|
||||
setManualReturnStep(BrowserHandoffStep.READY);
|
||||
setStep(BrowserHandoffStep.READY);
|
||||
const handleEnded = useCallback(() => {
|
||||
setStep(BrowserHandoffStep.PROBLEM);
|
||||
}, []);
|
||||
const {
|
||||
canStart,
|
||||
copied,
|
||||
copyCode,
|
||||
displayCode,
|
||||
error,
|
||||
expireCurrentSession,
|
||||
hasCode,
|
||||
hasStoppedWaiting,
|
||||
isCurrentSessionExpired,
|
||||
isExpired,
|
||||
isGenerating,
|
||||
openBrowser,
|
||||
pendingAction,
|
||||
regenerateCode,
|
||||
remainingSeconds,
|
||||
resumeWaiting,
|
||||
showManualCode,
|
||||
} = useBrowserLoginHandoff({
|
||||
const handoff = useBrowserLoginHandoff({
|
||||
runtimeSnapshot,
|
||||
prefillIdentifier,
|
||||
messages: handoffMessages,
|
||||
onExpired: handleExpired,
|
||||
onEnded: handleEnded,
|
||||
onSuccess,
|
||||
});
|
||||
const {canStart, openBrowser, regenerateCode, reset, showManualCode} = handoff;
|
||||
const showProblem = useCallback((from: RetryStep) => {
|
||||
setRetryStep(from);
|
||||
setStep(BrowserHandoffStep.PROBLEM);
|
||||
}, []);
|
||||
const handleOpenBrowser = useCallback(() => {
|
||||
setStep(BrowserHandoffStep.WAITING);
|
||||
openBrowser()
|
||||
.then((opened) => {
|
||||
if (opened) {
|
||||
setStep(BrowserHandoffStep.WAITING);
|
||||
if (!opened) {
|
||||
showProblem(BrowserHandoffStep.WAITING);
|
||||
}
|
||||
})
|
||||
.catch((caught) => {
|
||||
logger.error('Failed to open the browser sign-in page', caught);
|
||||
});
|
||||
}, [openBrowser]);
|
||||
const handleShowCode = useCallback(() => {
|
||||
const returnStep = resolveManualReturnStep(step);
|
||||
}, [openBrowser, showProblem]);
|
||||
const handleOtherDevice = useCallback(() => {
|
||||
setRetryStep(BrowserHandoffStep.OTHER_DEVICE);
|
||||
setStep(BrowserHandoffStep.OTHER_DEVICE);
|
||||
showManualCode()
|
||||
.then((shown) => {
|
||||
if (shown) {
|
||||
setManualReturnStep(returnStep);
|
||||
setStep(BrowserHandoffStep.MANUAL);
|
||||
if (!shown) {
|
||||
showProblem(BrowserHandoffStep.OTHER_DEVICE);
|
||||
}
|
||||
})
|
||||
.catch((caught) => {
|
||||
logger.error('Failed to reveal the browser sign-in code', caught);
|
||||
logger.error('Failed to show the browser sign-in code', caught);
|
||||
});
|
||||
}, [showManualCode, step]);
|
||||
const handleRegenerateCode = useCallback(() => {
|
||||
regenerateCode()
|
||||
.then((generated) => {
|
||||
if (!generated) {
|
||||
setStep(BrowserHandoffStep.READY);
|
||||
}
|
||||
})
|
||||
.catch((caught) => {
|
||||
logger.error('Failed to regenerate the browser sign-in code', caught);
|
||||
});
|
||||
}, [regenerateCode]);
|
||||
const handleBackFromManualCode = useCallback(() => {
|
||||
if (isCurrentSessionExpired()) {
|
||||
expireCurrentSession();
|
||||
}, [showManualCode, showProblem]);
|
||||
const handleRetry = useCallback(() => {
|
||||
if (retryStep === BrowserHandoffStep.OTHER_DEVICE) {
|
||||
setStep(BrowserHandoffStep.OTHER_DEVICE);
|
||||
regenerateCode()
|
||||
.then((generated) => {
|
||||
if (!generated) {
|
||||
showProblem(BrowserHandoffStep.OTHER_DEVICE);
|
||||
}
|
||||
})
|
||||
.catch((caught) => {
|
||||
logger.error('Failed to create a new browser sign-in code', caught);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setStep(manualReturnStep);
|
||||
}, [expireCurrentSession, isCurrentSessionExpired, manualReturnStep]);
|
||||
const activeBackAction = step === BrowserHandoffStep.MANUAL ? handleBackFromManualCode : onBack;
|
||||
handleOpenBrowser();
|
||||
}, [handleOpenBrowser, regenerateCode, retryStep, showProblem]);
|
||||
const handleCancel = useCallback(() => {
|
||||
reset();
|
||||
onBack();
|
||||
}, [onBack, reset]);
|
||||
const handleBackToWaiting = useCallback(() => {
|
||||
setRetryStep(BrowserHandoffStep.WAITING);
|
||||
setStep(BrowserHandoffStep.WAITING);
|
||||
}, []);
|
||||
const startedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (startedRef.current) {
|
||||
return;
|
||||
}
|
||||
startedRef.current = true;
|
||||
if (canStart) {
|
||||
handleOpenBrowser();
|
||||
} else {
|
||||
setStep(BrowserHandoffStep.PROBLEM);
|
||||
}
|
||||
}, [canStart, handleOpenBrowser]);
|
||||
const activeBackAction = step === BrowserHandoffStep.OTHER_DEVICE ? handleBackToWaiting : handleCancel;
|
||||
useEffect(() => {
|
||||
if (onBackActionChange == null) {
|
||||
return;
|
||||
@@ -579,6 +420,124 @@ export const AuthLoginBrowserStep = observer(function AuthLoginBrowserStep({
|
||||
onBackActionChange(activeBackAction);
|
||||
return () => onBackActionChange(null);
|
||||
}, [activeBackAction, onBackActionChange]);
|
||||
const problemMessage = canStart
|
||||
? (handoff.error ?? i18n._(BROWSER_HANDOFF_FAILED_DESCRIPTOR))
|
||||
: i18n._(BROWSER_HANDOFF_UNSUPPORTED_INSTANCE_DESCRIPTOR, {instanceName: instanceIdentity.name});
|
||||
const isOpening = handoff.isGenerating && handoff.pendingAction === BrowserLoginHandoffAction.OPEN_BROWSER;
|
||||
const isMakingCode = handoff.isGenerating && handoff.pendingAction === BrowserLoginHandoffAction.SHOW_CODE;
|
||||
let content: ReactNode;
|
||||
let actions: ReactNode;
|
||||
if (step === BrowserHandoffStep.OTHER_DEVICE) {
|
||||
content = (
|
||||
<OtherDeviceContent
|
||||
handoff={handoff}
|
||||
signInAddress={resolveSignInAddress(runtimeSnapshot.webAppEndpoint)}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-content"
|
||||
/>
|
||||
);
|
||||
actions = (
|
||||
<>
|
||||
{showBackButton ? (
|
||||
<Button
|
||||
variant={ButtonVariant.SECONDARY}
|
||||
leftIcon={<ButtonIcon icon="back" />}
|
||||
onClick={handleBackToWaiting}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.back-to-waiting"
|
||||
>
|
||||
{i18n._(BACK_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : null}
|
||||
{handoff.hasStoppedWaiting ? (
|
||||
<Button
|
||||
leftIcon={<ButtonIcon icon="retry" />}
|
||||
onClick={handoff.resumeWaiting}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.resume-waiting"
|
||||
>
|
||||
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
leftIcon={<ButtonIcon icon={handoff.copied ? 'copied' : 'copy'} />}
|
||||
onClick={handoff.copyCode}
|
||||
disabled={!handoff.hasCode}
|
||||
submitting={isMakingCode}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.copy-code"
|
||||
>
|
||||
{i18n._(handoff.copied ? COPIED_DESCRIPTOR : COPY_CODE_DESCRIPTOR)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (step === BrowserHandoffStep.PROBLEM) {
|
||||
content = (
|
||||
<ProblemContent
|
||||
message={problemMessage}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-content"
|
||||
/>
|
||||
);
|
||||
actions = (
|
||||
<>
|
||||
{showBackButton ? (
|
||||
<Button
|
||||
variant={ButtonVariant.SECONDARY}
|
||||
leftIcon={<ButtonIcon icon="back" />}
|
||||
onClick={handleCancel}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.back"
|
||||
>
|
||||
{i18n._(BACK_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : null}
|
||||
{canStart ? (
|
||||
<Button
|
||||
leftIcon={<ButtonIcon icon="retry" />}
|
||||
onClick={handleRetry}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.retry"
|
||||
>
|
||||
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<WaitingContent
|
||||
handoff={handoff}
|
||||
instanceName={instanceIdentity.name}
|
||||
onOtherDevice={handleOtherDevice}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-content"
|
||||
/>
|
||||
);
|
||||
actions = (
|
||||
<>
|
||||
<Button
|
||||
variant={ButtonVariant.SECONDARY}
|
||||
onClick={handleCancel}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.cancel"
|
||||
>
|
||||
{i18n._(CANCEL_DESCRIPTOR)}
|
||||
</Button>
|
||||
{handoff.hasStoppedWaiting ? (
|
||||
<Button
|
||||
leftIcon={<ButtonIcon icon="retry" />}
|
||||
onClick={handoff.resumeWaiting}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.resume-waiting"
|
||||
>
|
||||
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
leftIcon={<ButtonIcon icon="open" />}
|
||||
onClick={handleOpenBrowser}
|
||||
submitting={isOpening}
|
||||
disabled={!canStart}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.open-browser"
|
||||
>
|
||||
{i18n._(OPEN_BROWSER_AGAIN_DESCRIPTOR)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<flx-auth-login-browser-step
|
||||
className="flx-element"
|
||||
@@ -606,19 +565,12 @@ export const AuthLoginBrowserStep = observer(function AuthLoginBrowserStep({
|
||||
className={styles.browserInstanceLogo}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-instance-logo"
|
||||
>
|
||||
{instanceIdentity.isOfficial ? (
|
||||
<FluxerLogoAsset
|
||||
role="img"
|
||||
aria-label={OFFICIAL_INSTANCE_NAME}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.img"
|
||||
/>
|
||||
) : (
|
||||
<GlobeIcon
|
||||
size={remFromPx(20)}
|
||||
weight="regular"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.globe-icon"
|
||||
/>
|
||||
)}
|
||||
<InstanceBrandMark
|
||||
isOfficial={instanceIdentity.isOfficial}
|
||||
iconUrl={instanceIdentity.iconUrl}
|
||||
size={20}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.instance-brand-mark"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={styles.browserInstanceMeta}
|
||||
@@ -673,49 +625,19 @@ export const AuthLoginBrowserStep = observer(function AuthLoginBrowserStep({
|
||||
</Button>
|
||||
)}
|
||||
</flx-auth-login-browser-step-instance-row>
|
||||
{error != null && error.length > 0 ? (
|
||||
<div
|
||||
className={loginStyles.loginNotice}
|
||||
role="alert"
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<SteppedCarousel
|
||||
step={step}
|
||||
steps={BROWSER_STEPS}
|
||||
focusOnStepChange
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.carousel"
|
||||
>
|
||||
<BrowserStepContent
|
||||
canStart={canStart}
|
||||
displayCode={displayCode}
|
||||
hasStoppedWaiting={hasStoppedWaiting}
|
||||
instanceName={instanceIdentity.name}
|
||||
isExpired={isExpired}
|
||||
remainingSeconds={remainingSeconds}
|
||||
step={step}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content"
|
||||
/>
|
||||
<BrowserStepActions
|
||||
activeBackAction={activeBackAction}
|
||||
canStart={canStart}
|
||||
copied={copied}
|
||||
copyCode={copyCode}
|
||||
hasCode={hasCode}
|
||||
hasStoppedWaiting={hasStoppedWaiting}
|
||||
isExpired={isExpired}
|
||||
isGenerating={isGenerating}
|
||||
onOpenBrowser={handleOpenBrowser}
|
||||
onRegenerateCode={handleRegenerateCode}
|
||||
onResumeWaiting={resumeWaiting}
|
||||
onShowCode={handleShowCode}
|
||||
pendingAction={pendingAction}
|
||||
showBackButton={showBackButton}
|
||||
step={step}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions"
|
||||
/>
|
||||
{content}
|
||||
<SteppedCarouselActions
|
||||
className={styles.browserFooter}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-footer"
|
||||
>
|
||||
{actions}
|
||||
</SteppedCarouselActions>
|
||||
</SteppedCarousel>
|
||||
</flx-auth-login-browser-step-pane>
|
||||
</flx-auth-login-browser-step>
|
||||
|
||||
@@ -6,12 +6,12 @@ export const DesktopHandoffMode = Object.freeze({
|
||||
IDLE: 'idle',
|
||||
SELECTING: 'selecting',
|
||||
LOGIN: 'login',
|
||||
WARNING: 'warning',
|
||||
CODE_INPUT: 'code_input',
|
||||
FETCHING_INFO: 'fetching_info',
|
||||
APPROVING: 'approving',
|
||||
COMPLETING: 'completing',
|
||||
DONE: 'done',
|
||||
DENIED: 'denied',
|
||||
ERROR: 'error',
|
||||
} as const);
|
||||
|
||||
@@ -19,12 +19,12 @@ export type DesktopHandoffMode = (typeof DesktopHandoffMode)[keyof typeof Deskto
|
||||
|
||||
export function isApprovalFlowMode(mode: DesktopHandoffMode): boolean {
|
||||
switch (mode) {
|
||||
case DesktopHandoffMode.WARNING:
|
||||
case DesktopHandoffMode.CODE_INPUT:
|
||||
case DesktopHandoffMode.FETCHING_INFO:
|
||||
case DesktopHandoffMode.APPROVING:
|
||||
case DesktopHandoffMode.COMPLETING:
|
||||
case DesktopHandoffMode.DONE:
|
||||
case DesktopHandoffMode.DENIED:
|
||||
case DesktopHandoffMode.ERROR:
|
||||
return true;
|
||||
case DesktopHandoffMode.IDLE:
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import type {
|
||||
DesktopHandoffInfoResponse,
|
||||
DesktopHandoffReturnMethod,
|
||||
} from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/AuthLoginStepTypes';
|
||||
import type {UserData} from '@app/features/auth/state/AccountStorage';
|
||||
import {type InstanceHTTPTarget, instanceTargetFromSnapshot} from '@app/features/platform/transport/InstanceHTTP';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as FormUtils from '@app/lib/forms';
|
||||
import {formatDesktopHandoffCode, parseDesktopHandoffCode} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {useCallback, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const INVALID_OR_EXPIRED_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
|
||||
message: 'Invalid or expired code. Try again.',
|
||||
comment: 'Desktop handoff flow error shown when the entered pairing code is invalid or expired.',
|
||||
const HANDOFF_REQUEST_EXPIRED_DESCRIPTOR = msg({
|
||||
message: 'This sign-in request expired or was cancelled in the {productName} app. Start again from the app.',
|
||||
comment:
|
||||
'Error in the browser when the sign-in request from the desktop app no longer exists, because it expired or the app cancelled it. productName is the app name, such as Fluxer.',
|
||||
});
|
||||
const TYPED_CODE_NOT_FOUND_DESCRIPTOR = msg({
|
||||
message: 'That code is not valid on {instanceHost}. Check the code in the {productName} app and try again.',
|
||||
comment:
|
||||
'Error in the browser when a code typed from the app is not found. instanceHost is the server the browser checked, such as api.fluxer.app. productName is the app name, such as Fluxer.',
|
||||
});
|
||||
const COULDN_T_VERIFY_THAT_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
|
||||
message: "Couldn't verify that code. Try again.",
|
||||
@@ -22,11 +35,66 @@ const COULDN_T_COMPLETE_SIGN_IN_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
|
||||
message: "Couldn't complete sign-in. Try again.",
|
||||
comment: 'Desktop handoff flow error shown when finalizing the sign-in fails after a valid code.',
|
||||
});
|
||||
const INSTANCE_MISMATCH_DESCRIPTOR = msg({
|
||||
message:
|
||||
'This account is on {accountHost}, but the {productName} app is signing in to {requestHost}. Choose an account on {requestHost}.',
|
||||
comment:
|
||||
'Error in the browser when the chosen account belongs to a different server than the app that asked to sign in. accountHost and requestHost are server addresses. productName is the app name, such as Fluxer.',
|
||||
});
|
||||
|
||||
const logger = new Logger('useDesktopHandoffFlow');
|
||||
|
||||
export const DesktopHandoffCodeSource = Object.freeze({
|
||||
LINK: 'link',
|
||||
TYPED: 'typed',
|
||||
} as const);
|
||||
|
||||
export type DesktopHandoffCodeSource = (typeof DesktopHandoffCodeSource)[keyof typeof DesktopHandoffCodeSource];
|
||||
|
||||
export function isHandoffRequest(params: URLSearchParams): boolean {
|
||||
return params.get('handoff') === '1' || params.get('desktop_handoff') === '1';
|
||||
}
|
||||
|
||||
export interface DesktopHandoffRequest {
|
||||
readonly code: string | null;
|
||||
readonly apiOrigin: string | null;
|
||||
readonly identifier: string | null;
|
||||
}
|
||||
|
||||
function readOrigin(value: string | null | undefined): string | null {
|
||||
if (value == null || value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readDesktopHandoffRequest(search: string): DesktopHandoffRequest {
|
||||
const params = new URLSearchParams(search);
|
||||
const code = parseDesktopHandoffCode(params.get('code') ?? '');
|
||||
return {
|
||||
code: code == null ? null : formatDesktopHandoffCode(code),
|
||||
apiOrigin: readOrigin(params.get('api')),
|
||||
identifier: params.get('email') ?? params.get('login'),
|
||||
};
|
||||
}
|
||||
|
||||
function hostOf(endpoint: string): string {
|
||||
try {
|
||||
return new URL(endpoint).host;
|
||||
} catch {
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesIdentifier(identifier: string, userData: UserData): boolean {
|
||||
const wanted = identifier.trim().toLowerCase();
|
||||
return wanted === userData.username.toLowerCase() || wanted === userData.email?.toLowerCase();
|
||||
}
|
||||
|
||||
interface Options {
|
||||
enabled: boolean;
|
||||
hasStoredAccounts: boolean;
|
||||
@@ -37,6 +105,7 @@ export interface DesktopHandoffAccountCredentials {
|
||||
readonly token: string;
|
||||
readonly userId: string;
|
||||
readonly runtimeSnapshot: RuntimeConfigSnapshot;
|
||||
readonly userData?: UserData;
|
||||
}
|
||||
|
||||
export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}: Options) {
|
||||
@@ -46,35 +115,26 @@ export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}:
|
||||
if (initialMode) return initialMode;
|
||||
return hasStoredAccounts ? DesktopHandoffMode.SELECTING : DesktopHandoffMode.LOGIN;
|
||||
}, [enabled, hasStoredAccounts, initialMode]);
|
||||
const [request] = useState(() => readDesktopHandoffRequest(window.location.search));
|
||||
const [mode, setMode] = useState<DesktopHandoffMode>(derivedInitial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [clientInfo, setClientInfo] = useState<DesktopHandoffInfoResponse['client_info']>(null);
|
||||
const [handoffCode, setHandoffCode] = useState<string | null>(null);
|
||||
const [returnMethod, setReturnMethod] = useState<DesktopHandoffReturnMethod | null>(null);
|
||||
const [codeSource, setCodeSource] = useState<DesktopHandoffCodeSource | null>(null);
|
||||
const [returnUrl, setReturnUrl] = useState<string | null>(null);
|
||||
const [account, setAccount] = useState<UserData | null>(null);
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
const userIdRef = useRef<string | null>(null);
|
||||
const operationRevisionRef = useRef(0);
|
||||
const targetRef = useRef<InstanceHTTPTarget | null>(null);
|
||||
const start = useCallback(
|
||||
({token, userId, runtimeSnapshot}: DesktopHandoffAccountCredentials) => {
|
||||
if (!enabled) return;
|
||||
operationRevisionRef.current += 1;
|
||||
tokenRef.current = token;
|
||||
userIdRef.current = userId;
|
||||
targetRef.current = instanceTargetFromSnapshot(runtimeSnapshot);
|
||||
setError(null);
|
||||
setHandoffCode(null);
|
||||
setClientInfo(null);
|
||||
setMode(DesktopHandoffMode.WARNING);
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
const proceedToCodeInput = useCallback(() => {
|
||||
operationRevisionRef.current += 1;
|
||||
setMode(DesktopHandoffMode.CODE_INPUT);
|
||||
setError(null);
|
||||
const linkCodeUsedRef = useRef(false);
|
||||
const failWith = useCallback((message: string) => {
|
||||
setMode(DesktopHandoffMode.ERROR);
|
||||
setError(message);
|
||||
}, []);
|
||||
const submitCode = useCallback(
|
||||
async (code: string) => {
|
||||
const lookUpCode = useCallback(
|
||||
async (code: string, source: DesktopHandoffCodeSource) => {
|
||||
const token = tokenRef.current;
|
||||
const target = targetRef.current;
|
||||
if (token == null || token.length === 0 || target == null) return;
|
||||
@@ -82,61 +142,118 @@ export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}:
|
||||
setMode(DesktopHandoffMode.FETCHING_INFO);
|
||||
setError(null);
|
||||
setHandoffCode(code);
|
||||
setCodeSource(source);
|
||||
try {
|
||||
const info = await AuthenticationCommands.fetchDesktopHandoffInfo({code, token, target});
|
||||
if (operationRevision !== operationRevisionRef.current) return;
|
||||
if (info.status === 'expired') {
|
||||
setMode(DesktopHandoffMode.ERROR);
|
||||
setError(i18n._(INVALID_OR_EXPIRED_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR));
|
||||
failWith(
|
||||
source === DesktopHandoffCodeSource.LINK
|
||||
? i18n._(HANDOFF_REQUEST_EXPIRED_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
: i18n._(TYPED_CODE_NOT_FOUND_DESCRIPTOR, {
|
||||
instanceHost: hostOf(target.apiEndpoint),
|
||||
productName: PRODUCT_NAME,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setClientInfo(info.client_info);
|
||||
setReturnMethod(
|
||||
source === DesktopHandoffCodeSource.LINK && info.return_method === 'deep_link' ? 'deep_link' : 'code',
|
||||
);
|
||||
setMode(DesktopHandoffMode.APPROVING);
|
||||
} catch (e) {
|
||||
if (operationRevision !== operationRevisionRef.current) return;
|
||||
setMode(DesktopHandoffMode.ERROR);
|
||||
setError(
|
||||
failWith(
|
||||
e && typeof e === 'object' && 'body' in e
|
||||
? FormUtils.extractErrorMessage(i18n, e)
|
||||
: i18n._(COULDN_T_VERIFY_THAT_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR),
|
||||
);
|
||||
}
|
||||
},
|
||||
[i18n],
|
||||
[failWith, i18n],
|
||||
);
|
||||
const start = useCallback(
|
||||
({token, userId, runtimeSnapshot, userData}: DesktopHandoffAccountCredentials) => {
|
||||
if (!enabled) return;
|
||||
operationRevisionRef.current += 1;
|
||||
tokenRef.current = token;
|
||||
userIdRef.current = userId;
|
||||
targetRef.current = instanceTargetFromSnapshot(runtimeSnapshot);
|
||||
setAccount(userData ?? null);
|
||||
setError(null);
|
||||
setHandoffCode(null);
|
||||
setClientInfo(null);
|
||||
setReturnMethod(null);
|
||||
setReturnUrl(null);
|
||||
const accountOrigin = readOrigin(runtimeSnapshot.apiEndpoint);
|
||||
if (request.apiOrigin != null && accountOrigin != null && request.apiOrigin !== accountOrigin) {
|
||||
failWith(
|
||||
i18n._(INSTANCE_MISMATCH_DESCRIPTOR, {
|
||||
accountHost: hostOf(accountOrigin),
|
||||
requestHost: hostOf(request.apiOrigin),
|
||||
productName: PRODUCT_NAME,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.code != null && !linkCodeUsedRef.current) {
|
||||
linkCodeUsedRef.current = true;
|
||||
void lookUpCode(request.code, DesktopHandoffCodeSource.LINK);
|
||||
return;
|
||||
}
|
||||
setMode(DesktopHandoffMode.CODE_INPUT);
|
||||
},
|
||||
[enabled, failWith, i18n, lookUpCode, request],
|
||||
);
|
||||
const submitCode = useCallback((code: string) => lookUpCode(code, DesktopHandoffCodeSource.TYPED), [lookUpCode]);
|
||||
const approve = useCallback(async () => {
|
||||
const token = tokenRef.current;
|
||||
const userId = userIdRef.current;
|
||||
const target = targetRef.current;
|
||||
if (!handoffCode || !token || !userId || target == null) return;
|
||||
if (!handoffCode || !token || !userId || target == null || returnMethod == null) return;
|
||||
const operationRevision = ++operationRevisionRef.current;
|
||||
setMode(DesktopHandoffMode.COMPLETING);
|
||||
setError(null);
|
||||
try {
|
||||
await AuthenticationCommands.completeDesktopHandoff({
|
||||
const completedReturnUrl = await AuthenticationCommands.completeDesktopHandoff({
|
||||
code: handoffCode,
|
||||
token,
|
||||
userId,
|
||||
returnMethod,
|
||||
target,
|
||||
});
|
||||
if (operationRevision !== operationRevisionRef.current) return;
|
||||
setReturnUrl(completedReturnUrl);
|
||||
setMode(DesktopHandoffMode.DONE);
|
||||
if (completedReturnUrl != null) {
|
||||
window.location.assign(completedReturnUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
if (operationRevision !== operationRevisionRef.current) return;
|
||||
setMode(DesktopHandoffMode.ERROR);
|
||||
setError(
|
||||
failWith(
|
||||
e && typeof e === 'object' && 'body' in e
|
||||
? FormUtils.extractErrorMessage(i18n, e)
|
||||
: i18n._(COULDN_T_COMPLETE_SIGN_IN_PLEASE_TRY_AGAIN_DESCRIPTOR),
|
||||
);
|
||||
}
|
||||
}, [handoffCode, i18n]);
|
||||
}, [failWith, handoffCode, i18n, returnMethod]);
|
||||
const deny = useCallback(() => {
|
||||
const target = targetRef.current;
|
||||
const code = handoffCode;
|
||||
operationRevisionRef.current += 1;
|
||||
setHandoffCode(null);
|
||||
setClientInfo(null);
|
||||
setMode(DesktopHandoffMode.CODE_INPUT);
|
||||
}, []);
|
||||
setMode(DesktopHandoffMode.DENIED);
|
||||
if (code == null || target == null) return;
|
||||
AuthenticationCommands.denyDesktopHandoff({code, target}).catch((caught) => {
|
||||
logger.warn('Failed to tell the server that the sign-in request was declined', caught);
|
||||
});
|
||||
}, [handoffCode]);
|
||||
const reopenApp = useCallback(() => {
|
||||
if (returnUrl != null) {
|
||||
window.location.assign(returnUrl);
|
||||
}
|
||||
}, [returnUrl]);
|
||||
const switchToLogin = useCallback(() => {
|
||||
operationRevisionRef.current += 1;
|
||||
setMode(DesktopHandoffMode.LOGIN);
|
||||
@@ -147,22 +264,34 @@ export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}:
|
||||
setError(null);
|
||||
setHandoffCode(null);
|
||||
setClientInfo(null);
|
||||
setReturnMethod(null);
|
||||
setReturnUrl(null);
|
||||
setAccount(null);
|
||||
tokenRef.current = null;
|
||||
userIdRef.current = null;
|
||||
targetRef.current = null;
|
||||
setMode(hasStoredAccounts ? DesktopHandoffMode.SELECTING : DesktopHandoffMode.LOGIN);
|
||||
}, [hasStoredAccounts]);
|
||||
const isWrongAccount =
|
||||
request.identifier != null && account != null && !matchesIdentifier(request.identifier, account);
|
||||
return {
|
||||
mode,
|
||||
error,
|
||||
clientInfo,
|
||||
handoffCode,
|
||||
codeSource,
|
||||
returnMethod,
|
||||
returnUrl,
|
||||
account,
|
||||
requestedIdentifier: isWrongAccount ? request.identifier : null,
|
||||
start,
|
||||
proceedToCodeInput,
|
||||
submitCode,
|
||||
approve,
|
||||
deny,
|
||||
reopenApp,
|
||||
switchToLogin,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
||||
export type DesktopHandoffFlow = ReturnType<typeof useDesktopHandoffFlow>;
|
||||
|
||||
@@ -7,6 +7,11 @@ export const DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR = msg({
|
||||
comment: 'Error shown when the browser sign-in handoff cannot run in the current environment.',
|
||||
});
|
||||
export const DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR = msg({
|
||||
message: 'That sign-in code expired. Try again.',
|
||||
comment: 'Error shown when a browser sign-in handoff code expires before it is approved.',
|
||||
message: 'The sign-in request expired before your browser approved it.',
|
||||
comment:
|
||||
'Error shown in the desktop app when a browser sign-in request expires before it is approved in the browser.',
|
||||
});
|
||||
export const DESKTOP_HANDOFF_DENIED_DESCRIPTOR = msg({
|
||||
message: 'The sign-in was cancelled in your browser.',
|
||||
comment: 'Error shown in the desktop app when the user pressed Cancel on the browser sign-in page.',
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {UserData} from '@app/features/auth/state/AccountStorage';
|
||||
import type {InstanceHTTPTarget} from '@app/features/platform/transport/InstanceHTTP';
|
||||
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
|
||||
import type {DesktopHandoffAPI} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
|
||||
import {DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
|
||||
import {DesktopHandoffReturnMethod, DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
|
||||
|
||||
export interface BrowserLoginHandoffTarget {
|
||||
readonly webAppEndpoint: string;
|
||||
@@ -16,6 +16,7 @@ export interface BrowserLoginHandoffSession {
|
||||
readonly code: string;
|
||||
readonly expiresAt: string;
|
||||
readonly pollSecret: string | null;
|
||||
readonly returnMethod: DesktopHandoffReturnMethod;
|
||||
readonly target: BrowserLoginHandoffTarget;
|
||||
}
|
||||
|
||||
@@ -47,6 +48,9 @@ function normalizeHandoffStatus(status: string): DesktopHandoffStatus {
|
||||
if (status === DesktopHandoffStatus.EXPIRED) {
|
||||
return DesktopHandoffStatus.EXPIRED;
|
||||
}
|
||||
if (status === DesktopHandoffStatus.DENIED) {
|
||||
return DesktopHandoffStatus.DENIED;
|
||||
}
|
||||
return DesktopHandoffStatus.PENDING;
|
||||
}
|
||||
|
||||
@@ -55,7 +59,13 @@ function createRendererHandoffTransport(): BrowserLoginHandoffTransport {
|
||||
kind: BrowserLoginHandoffTransportKind.RENDERER,
|
||||
initiate: async (target) => {
|
||||
const result = await AuthenticationCommands.initiateDesktopHandoff(target.instance);
|
||||
return {code: result.code, expiresAt: result.expires_at, pollSecret: result.poll_secret ?? null, target};
|
||||
return {
|
||||
code: result.code,
|
||||
expiresAt: result.expires_at,
|
||||
pollSecret: result.poll_secret ?? null,
|
||||
returnMethod: DesktopHandoffReturnMethod.CODE,
|
||||
target,
|
||||
};
|
||||
},
|
||||
status: async (session) => {
|
||||
const result = await AuthenticationCommands.pollDesktopHandoffStatus(
|
||||
@@ -86,6 +96,10 @@ function createIpcHandoffTransport(api: DesktopHandoffAPI): BrowserLoginHandoffT
|
||||
code: session.code,
|
||||
expiresAt: session.expiresAt,
|
||||
pollSecret: null,
|
||||
returnMethod:
|
||||
session.returnMethod === DesktopHandoffReturnMethod.DEEP_LINK
|
||||
? DesktopHandoffReturnMethod.DEEP_LINK
|
||||
: DesktopHandoffReturnMethod.CODE,
|
||||
target: {webAppEndpoint: session.instance.webAppEndpoint, instance: target.instance},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -88,6 +88,7 @@ function createDesktopHandoffAPI(): DesktopHandoffAPI & {
|
||||
instance,
|
||||
code: CODE,
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
returnMethod: 'deep_link',
|
||||
};
|
||||
return session;
|
||||
},
|
||||
@@ -107,17 +108,17 @@ let root: Root;
|
||||
let controller: Controller | null = null;
|
||||
|
||||
function Probe({
|
||||
onExpired,
|
||||
onEnded,
|
||||
onSuccess,
|
||||
runtimeSnapshot,
|
||||
}: {
|
||||
onExpired: () => void;
|
||||
onEnded: () => void;
|
||||
onSuccess: (payload: {token: string; userId: string}) => Promise<void> | void;
|
||||
runtimeSnapshot: typeof RUNTIME_SNAPSHOT;
|
||||
}) {
|
||||
controller = useBrowserLoginHandoff({
|
||||
runtimeSnapshot,
|
||||
onExpired,
|
||||
onEnded,
|
||||
onSuccess,
|
||||
});
|
||||
return null;
|
||||
@@ -133,10 +134,10 @@ function requireController(): Controller {
|
||||
async function mountProbe(
|
||||
onSuccess: (payload: {token: string; userId: string}) => Promise<void> | void,
|
||||
runtimeSnapshot: typeof RUNTIME_SNAPSHOT = RUNTIME_SNAPSHOT,
|
||||
onExpired: () => void = () => {},
|
||||
onEnded: () => void = () => {},
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(createElement(Probe, {onExpired, onSuccess, runtimeSnapshot}));
|
||||
root.render(createElement(Probe, {onEnded, onSuccess, runtimeSnapshot}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,7 +218,7 @@ describe('browser sign-in against a deployed desktop shell', () => {
|
||||
expect(harness.initiateDesktopHandoff).toHaveBeenCalledTimes(1);
|
||||
expect(harness.navigateToExternalURL).toHaveBeenCalledTimes(1);
|
||||
expect(harness.navigateToExternalURL.mock.calls[0]?.[0]).toBe(
|
||||
`https://self.hosted.example/login?handoff=1&code=${encodeURIComponent(CODE)}`,
|
||||
`https://self.hosted.example/login?handoff=1&code=${encodeURIComponent(CODE)}&api=${encodeURIComponent('https://self.hosted.example')}`,
|
||||
);
|
||||
|
||||
await advancePollInterval();
|
||||
@@ -491,7 +492,7 @@ describe('when the instance reports the code as dead', () => {
|
||||
expect(harness.pollDesktopHandoffStatus).toHaveBeenCalledTimes(1);
|
||||
expect(requireController().session).toBeNull();
|
||||
expect(requireController().code).toBeNull();
|
||||
expect(requireController().error).toBe('That sign-in code expired. Try again.');
|
||||
expect(requireController().error).toBe('The sign-in request expired before your browser approved it.');
|
||||
|
||||
await advancePollInterval();
|
||||
await advancePollInterval();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import {
|
||||
DESKTOP_HANDOFF_DENIED_DESCRIPTOR,
|
||||
DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR,
|
||||
DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR,
|
||||
} from '@app/features/auth/flow/browser_handoff/BrowserHandoffDescriptors';
|
||||
@@ -17,9 +18,8 @@ import {instanceTargetFromSnapshot} from '@app/features/platform/transport/Insta
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
|
||||
import {navigateToExternalURL} from '@app/features/ui/utils/NativeUtils';
|
||||
import {DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
|
||||
import {DesktopHandoffReturnMethod, DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
|
||||
import {formatDesktopHandoffCode} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
|
||||
import type {I18n, MessageDescriptor} from '@lingui/core';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
@@ -38,16 +38,10 @@ export const BrowserLoginHandoffAction = Object.freeze({
|
||||
export type BrowserLoginHandoffAction = (typeof BrowserLoginHandoffAction)[keyof typeof BrowserLoginHandoffAction];
|
||||
export type BrowserLoginHandoffPendingAction = BrowserLoginHandoffAction | null;
|
||||
|
||||
export interface BrowserLoginHandoffMessages {
|
||||
readonly desktopHandoffUnavailable?: MessageDescriptor;
|
||||
readonly expired?: MessageDescriptor;
|
||||
}
|
||||
|
||||
export interface UseBrowserLoginHandoffOptions {
|
||||
readonly runtimeSnapshot: RuntimeConfigSnapshot;
|
||||
readonly prefillIdentifier?: string | null;
|
||||
readonly messages?: BrowserLoginHandoffMessages;
|
||||
readonly onExpired: () => void;
|
||||
readonly onEnded: () => void;
|
||||
readonly onSuccess: (payload: LoginSuccessPayload) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -63,10 +57,9 @@ export interface BrowserLoginHandoffController {
|
||||
readonly isGenerating: boolean;
|
||||
readonly pendingAction: BrowserLoginHandoffPendingAction;
|
||||
readonly remainingSeconds: number | null;
|
||||
readonly returnMethod: DesktopHandoffReturnMethod;
|
||||
readonly session: BrowserLoginHandoffSession | null;
|
||||
readonly copyCode: () => void;
|
||||
readonly expireCurrentSession: () => void;
|
||||
readonly isCurrentSessionExpired: () => boolean;
|
||||
readonly openBrowser: () => Promise<boolean>;
|
||||
readonly regenerateCode: () => Promise<boolean>;
|
||||
readonly reset: () => void;
|
||||
@@ -97,7 +90,11 @@ function buildBrowserLoginHandoffURL({
|
||||
prefillIdentifier,
|
||||
prefillField,
|
||||
}: BrowserLoginHandoffURLRequest): string {
|
||||
const params = new URLSearchParams({handoff: '1', code: session.code});
|
||||
const params = new URLSearchParams({
|
||||
handoff: '1',
|
||||
code: session.code,
|
||||
api: new URL(session.target.instance.apiEndpoint).origin,
|
||||
});
|
||||
if (prefillIdentifier != null && prefillIdentifier.length > 0) {
|
||||
params.set(prefillField, prefillIdentifier);
|
||||
}
|
||||
@@ -131,15 +128,10 @@ function useBrowserLoginHandoffCountdown(expiresAt: string | null): number | nul
|
||||
return remaining;
|
||||
}
|
||||
|
||||
function resolveMessage(i18n: I18n, descriptor: MessageDescriptor | undefined, fallback: MessageDescriptor): string {
|
||||
return i18n._(descriptor ?? fallback);
|
||||
}
|
||||
|
||||
export function useBrowserLoginHandoff({
|
||||
runtimeSnapshot,
|
||||
prefillIdentifier,
|
||||
messages,
|
||||
onExpired,
|
||||
onEnded,
|
||||
onSuccess,
|
||||
}: UseBrowserLoginHandoffOptions): BrowserLoginHandoffController {
|
||||
const {i18n} = useLingui();
|
||||
@@ -155,10 +147,10 @@ export function useBrowserLoginHandoff({
|
||||
const completedRef = useRef(false);
|
||||
const copyResetRef = useRef<number | null>(null);
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
const onExpiredRef = useRef(onExpired);
|
||||
const onEndedRef = useRef(onEnded);
|
||||
const i18nRef = useRef(i18n);
|
||||
onSuccessRef.current = onSuccess;
|
||||
onExpiredRef.current = onExpired;
|
||||
onEndedRef.current = onEnded;
|
||||
i18nRef.current = i18n;
|
||||
const target = resolveHandoffTarget(runtimeSnapshot);
|
||||
const targetKey = `${target.instance.apiEndpoint}|${target.webAppEndpoint}`;
|
||||
@@ -205,9 +197,7 @@ export function useBrowserLoginHandoff({
|
||||
const requestTarget = targetRef.current;
|
||||
try {
|
||||
if (requestTarget.webAppEndpoint.length === 0) {
|
||||
throw new BrowserLoginHandoffUnavailableError(
|
||||
resolveMessage(i18n, messages?.desktopHandoffUnavailable, DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR),
|
||||
);
|
||||
throw new BrowserLoginHandoffUnavailableError(i18n._(DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR));
|
||||
}
|
||||
const nextSession = await transport.initiate(requestTarget);
|
||||
if (generation !== generationRef.current) {
|
||||
@@ -225,7 +215,7 @@ export function useBrowserLoginHandoff({
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}
|
||||
}, [clearCopyFeedbackTimer, i18n, messages?.desktopHandoffUnavailable, transport]);
|
||||
}, [clearCopyFeedbackTimer, i18n, transport]);
|
||||
const getCurrentSession = useCallback(async (): Promise<BrowserLoginHandoffSession | null> => {
|
||||
if (session != null && Date.parse(session.expiresAt) > Date.now()) {
|
||||
return session;
|
||||
@@ -233,17 +223,17 @@ export function useBrowserLoginHandoff({
|
||||
return generateSession();
|
||||
}, [generateSession, session]);
|
||||
const expireCurrentSession = useCallback(() => {
|
||||
clearHandoffSession(resolveMessage(i18n, messages?.expired, DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR));
|
||||
onExpiredRef.current();
|
||||
}, [clearHandoffSession, i18n, messages?.expired]);
|
||||
clearHandoffSession(i18n._(DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR));
|
||||
onEndedRef.current();
|
||||
}, [clearHandoffSession, i18n]);
|
||||
const expireCurrentSessionRef = useRef(expireCurrentSession);
|
||||
expireCurrentSessionRef.current = expireCurrentSession;
|
||||
const isCurrentSessionExpired = useCallback(() => {
|
||||
if (session == null) {
|
||||
return false;
|
||||
}
|
||||
return Date.parse(session.expiresAt) <= Date.now();
|
||||
}, [session]);
|
||||
const endDeniedSession = useCallback(() => {
|
||||
clearHandoffSession(i18n._(DESKTOP_HANDOFF_DENIED_DESCRIPTOR));
|
||||
onEndedRef.current();
|
||||
}, [clearHandoffSession, i18n]);
|
||||
const endDeniedSessionRef = useRef(endDeniedSession);
|
||||
endDeniedSessionRef.current = endDeniedSession;
|
||||
const openBrowser = useCallback(async (): Promise<boolean> => {
|
||||
setPendingAction(BrowserLoginHandoffAction.OPEN_BROWSER);
|
||||
try {
|
||||
@@ -362,6 +352,10 @@ export function useBrowserLoginHandoff({
|
||||
keepPolling = false;
|
||||
expireCurrentSessionRef.current();
|
||||
}
|
||||
if (result.status === DesktopHandoffStatus.DENIED) {
|
||||
keepPolling = false;
|
||||
endDeniedSessionRef.current();
|
||||
}
|
||||
} catch (caught) {
|
||||
consecutiveErrors += 1;
|
||||
if (!disposed) {
|
||||
@@ -402,10 +396,9 @@ export function useBrowserLoginHandoff({
|
||||
isGenerating,
|
||||
pendingAction,
|
||||
remainingSeconds,
|
||||
returnMethod: session?.returnMethod ?? DesktopHandoffReturnMethod.CODE,
|
||||
session,
|
||||
copyCode,
|
||||
expireCurrentSession,
|
||||
isCurrentSessionExpired,
|
||||
openBrowser,
|
||||
regenerateCode,
|
||||
reset,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {InstanceBrandMark} from '@app/features/auth/components/InstanceBrandMark';
|
||||
import styles from '@app/features/auth/flow/InstanceSelector.module.css';
|
||||
import {
|
||||
type InstanceInfo,
|
||||
@@ -7,6 +8,8 @@ import {
|
||||
resolveInstanceLabel,
|
||||
} from '@app/features/auth/flow/instance_selector/InstanceDirectoryStorage';
|
||||
import {InstanceDiscoveryStatus} from '@app/features/auth/flow/instance_selector/InstanceSelectorTypes';
|
||||
import {resolveInstanceBrandIconUrl, resolveInstanceProductName} from '@app/features/auth/InstanceBranding';
|
||||
import {findInstanceSnapshot} from '@app/features/auth/InstanceSnapshotLookup';
|
||||
import {OFFICIAL_INSTANCE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {MenuItem} from '@app/features/ui/action_menu/MenuItem';
|
||||
@@ -18,7 +21,7 @@ import FluxerLogoAsset from '@app/media/images/fluxer-logo-color.svg?react';
|
||||
import {OFFICIAL_INSTANCE_DISPLAY_HOST, OFFICIAL_INSTANCE_NAME} from '@fluxer/instance_bootstrap/src/OfficialInstance';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {ArrowRightIcon, GlobeIcon, PlusIcon, TrashIcon} from '@phosphor-icons/react';
|
||||
import {ArrowRightIcon, PlusIcon, TrashIcon} from '@phosphor-icons/react';
|
||||
import {type MouseEvent, type ReactNode, useCallback} from 'react';
|
||||
|
||||
const USE_OFFICIAL_INSTANCE_DESCRIPTOR = msg({
|
||||
@@ -86,7 +89,10 @@ export function InstancePickerRows({
|
||||
[disabled, i18n, isDiscovering, onRemoveInstance],
|
||||
);
|
||||
const renderKnownInstance = (instance: InstanceInfo, removable: boolean): ReactNode => {
|
||||
const name = resolveInstanceLabel(instance.name, instance.domain);
|
||||
const snapshot = findInstanceSnapshot(instance.instanceKey);
|
||||
const storedName = instance.name !== instance.domain ? instance.name : null;
|
||||
const name = resolveInstanceLabel(storedName ?? resolveInstanceProductName(snapshot), instance.domain);
|
||||
const iconUrl = resolveInstanceBrandIconUrl(snapshot);
|
||||
return (
|
||||
<div
|
||||
key={instance.instanceKey}
|
||||
@@ -111,10 +117,11 @@ export function InstancePickerRows({
|
||||
className={styles.instanceLogo}
|
||||
data-flx="auth.flow.instance-selector.instance-picker-rows.render-known-instance.instance-logo"
|
||||
>
|
||||
<GlobeIcon
|
||||
size={remFromPx(20)}
|
||||
weight="regular"
|
||||
data-flx="auth.flow.instance-selector.instance-picker-rows.render-known-instance.globe-icon"
|
||||
<InstanceBrandMark
|
||||
isOfficial={false}
|
||||
iconUrl={iconUrl}
|
||||
size={20}
|
||||
data-flx="auth.flow.instance-selector.instance-picker-rows.render-known-instance.instance-brand-mark"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import accountStorage from '@app/features/auth/state/AccountStorage';
|
||||
import type {UserData} from '@app/features/auth/state/AccountStorageContract';
|
||||
import Accounts from '@app/features/auth/state/Accounts';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
|
||||
const logger = new Logger('AccountUserDataSync');
|
||||
|
||||
export interface AccountUserIdentity {
|
||||
readonly username: string;
|
||||
readonly discriminator: string;
|
||||
readonly global_name?: string | null;
|
||||
readonly email?: string | null;
|
||||
readonly avatar?: string | null;
|
||||
}
|
||||
|
||||
export function syncAccountUserData(accountKey: string, user: AccountUserIdentity): void {
|
||||
const previous = Accounts.getAccount(accountKey)?.userData;
|
||||
const userData: UserData = {
|
||||
username: user.username,
|
||||
discriminator: user.discriminator,
|
||||
globalName: user.global_name === undefined ? previous?.globalName : user.global_name,
|
||||
email: user.email === undefined ? previous?.email : (user.email ?? undefined),
|
||||
avatar: user.avatar ?? undefined,
|
||||
};
|
||||
void accountStorage.updateAccountUserData(accountKey, userData).catch((error) => {
|
||||
logger.warn(`Failed to persist account user data for ${accountKey}`, error);
|
||||
});
|
||||
Accounts.updateAccountUserData(accountKey, userData);
|
||||
}
|
||||
@@ -36,7 +36,12 @@ const mocks = vi.hoisted(() => ({
|
||||
retireForAccountTransition: vi.fn<(reason: string) => Promise<void>>(() => Promise.resolve()),
|
||||
gatewayLogout: vi.fn<() => void>(),
|
||||
sendInvisiblePresence: vi.fn<() => void>(),
|
||||
replaceWith: vi.fn<(path: string) => void>(),
|
||||
replaceWith: vi.fn<(path: string) => void>((path) => {
|
||||
mocks.currentPath = path;
|
||||
mocks.commitRouterPath(path);
|
||||
}),
|
||||
readRouterPath: (): string => '',
|
||||
commitRouterPath: (_path: string): void => undefined,
|
||||
abandonUnreachableLastLocation: vi.fn<() => void>(),
|
||||
currentPath: '',
|
||||
lastLocation: null as string | null,
|
||||
@@ -80,6 +85,20 @@ vi.mock('@app/features/navigation/utils/RouterUtils', () => ({
|
||||
history: null,
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/navigation/state/Navigation', async () => {
|
||||
const {observable, runInAction} = await import('mobx');
|
||||
const routerPath = observable.box('');
|
||||
mocks.readRouterPath = () => routerPath.get();
|
||||
mocks.commitRouterPath = (path) => runInAction(() => routerPath.set(path));
|
||||
return {
|
||||
default: {
|
||||
get pathname() {
|
||||
return mocks.readRouterPath();
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@app/features/ui/state/Location', () => ({
|
||||
default: {getLastLocation: () => mocks.lastLocation, saveLocation: mocks.saveLocation},
|
||||
}));
|
||||
@@ -203,9 +222,11 @@ function gatewayOutage(accountKey: string): InstanceType<typeof ForegroundGatewa
|
||||
|
||||
beforeEach(async () => {
|
||||
mocks.currentPath = '';
|
||||
mocks.commitRouterPath('');
|
||||
mocks.lastLocation = null;
|
||||
mocks.replaceWith.mockImplementation((path) => {
|
||||
mocks.currentPath = path;
|
||||
mocks.commitRouterPath(path);
|
||||
});
|
||||
mocks.foregroundAccountKey = null;
|
||||
mocks.hydrateForegroundStores(null);
|
||||
@@ -490,9 +511,11 @@ describe('Accounts view handover between accounts that share a guild', () => {
|
||||
mocks.currentPath = GENERAL_ROUTE;
|
||||
mocks.lastLocation = GENERAL_ROUTE;
|
||||
runInAction(() => routedPath.set(GENERAL_ROUTE));
|
||||
mocks.commitRouterPath(GENERAL_ROUTE);
|
||||
mocks.replaceWith.mockImplementation((path) => {
|
||||
mocks.currentPath = path;
|
||||
runInAction(() => routedPath.set(path));
|
||||
mocks.commitRouterPath(path);
|
||||
redirectUnknownSharedGuildChannel(path);
|
||||
});
|
||||
mocks.saveLocation.mockImplementation((path) => {
|
||||
@@ -581,6 +604,36 @@ describe('Accounts view handover between accounts that share a guild', () => {
|
||||
expect(rendered.filter((entry) => !entry.live).every((entry) => entry.path === GENERAL_ROUTE)).toBe(true);
|
||||
});
|
||||
|
||||
test('the target view stays held until the router commits the target route', async () => {
|
||||
mocks.replaceWith.mockImplementation((path) => {
|
||||
mocks.currentPath = path;
|
||||
setTimeout(() => {
|
||||
runInAction(() => routedPath.set(path));
|
||||
mocks.commitRouterPath(path);
|
||||
}, 0);
|
||||
});
|
||||
const rendered: Array<{accountKey: string | null; path: string; live: boolean}> = [];
|
||||
const ViewRecorder = observer(() => {
|
||||
rendered.push({accountKey: SessionManager.currentAccountKey, path: routedPath.get(), live: Accounts.isViewLive});
|
||||
return null;
|
||||
});
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
act(() => root.render(createElement(ViewRecorder)));
|
||||
disposers.push(() => act(() => root.unmount()));
|
||||
|
||||
await act(async () => {
|
||||
await Accounts.switchToAccount(accountB);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
expect(
|
||||
rendered.filter((entry) => entry.live && entry.accountKey === accountB && entry.path !== PRIVATE_ROUTE),
|
||||
).toEqual([]);
|
||||
expect(rendered.at(-1)).toEqual({accountKey: accountB, path: PRIVATE_ROUTE, live: true});
|
||||
});
|
||||
|
||||
test('the target route is in place in the same tick as its state, before the gateway handshake settles', async () => {
|
||||
await Accounts.switchToAccount(accountB);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {getAccountKey, parseAccountStorageKey} from '@app/features/auth/state/Ac
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import {ForegroundGatewayConnectionRecoverableError} from '@app/features/gateway/transport/ForegroundGatewayConnectionFailure';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import Navigation from '@app/features/navigation/state/Navigation';
|
||||
import {abandonUnreachableLastLocation} from '@app/features/navigation/utils/ChannelRouteReachability';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils';
|
||||
@@ -103,7 +104,10 @@ interface AccountReplacementCommitRequest {
|
||||
|
||||
type AccountViewReplacement =
|
||||
| {readonly kind: 'activation'; readonly redirectPath: string | null}
|
||||
| {readonly kind: 'rollback'; readonly previousRoute: string};
|
||||
| {readonly kind: 'rollback'; readonly previousRoute: string}
|
||||
| {readonly kind: 'navigating'; readonly stalePath: string};
|
||||
|
||||
const VIEW_NAVIGATION_COMMIT_TIMEOUT_MS = 2000;
|
||||
|
||||
type AccountActivationOutcome =
|
||||
| {readonly kind: 'ready'}
|
||||
@@ -478,7 +482,9 @@ class Accounts {
|
||||
throw activationError;
|
||||
} finally {
|
||||
stopAwaitingLiveView();
|
||||
this.viewReplacement = null;
|
||||
if (this.viewReplacement?.kind !== 'navigating') {
|
||||
this.viewReplacement = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,17 +618,44 @@ class Accounts {
|
||||
|
||||
private revealReplacementView(): void {
|
||||
const replacement = this.viewReplacement;
|
||||
if (replacement === null) {
|
||||
if (replacement === null || replacement.kind === 'navigating') {
|
||||
return;
|
||||
}
|
||||
const path = this.resolveReplacementViewPath(replacement);
|
||||
if (path !== null && path !== RouterUtils.getCurrentPath()) {
|
||||
RouterUtils.replaceWith(path);
|
||||
const stalePath = Navigation.pathname;
|
||||
if (path === null || path === RouterUtils.getCurrentPath()) {
|
||||
this.viewReplacement = null;
|
||||
return;
|
||||
}
|
||||
this.viewReplacement = null;
|
||||
RouterUtils.replaceWith(path);
|
||||
this.holdViewUntilNavigationCommits(stalePath);
|
||||
}
|
||||
|
||||
private resolveReplacementViewPath(replacement: AccountViewReplacement): string | null {
|
||||
private holdViewUntilNavigationCommits(stalePath: string): void {
|
||||
if (Navigation.pathname !== stalePath) {
|
||||
this.viewReplacement = null;
|
||||
return;
|
||||
}
|
||||
const marker: AccountViewReplacement = {kind: 'navigating', stalePath};
|
||||
this.viewReplacement = marker;
|
||||
const release = () => {
|
||||
if (this.viewReplacement === marker) {
|
||||
this.viewReplacement = null;
|
||||
}
|
||||
};
|
||||
const timeout = setTimeout(release, VIEW_NAVIGATION_COMMIT_TIMEOUT_MS);
|
||||
when(
|
||||
() => this.viewReplacement !== marker || Navigation.pathname !== stalePath,
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
release();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private resolveReplacementViewPath(
|
||||
replacement: Exclude<AccountViewReplacement, {readonly kind: 'navigating'}>,
|
||||
): string | null {
|
||||
if (replacement.kind === 'rollback') {
|
||||
return replacement.previousRoute === '' ? null : replacement.previousRoute;
|
||||
}
|
||||
|
||||
@@ -380,26 +380,3 @@ export async function pollIpAuthorization(
|
||||
): Promise<AuthenticationCommands.IpAuthorizationPollResult> {
|
||||
return AuthenticationCommands.pollIpAuthorization({ticket, target: authCommandTarget(runtimeSnapshot)});
|
||||
}
|
||||
|
||||
export async function initiateDesktopHandoff(runtimeSnapshot: RuntimeConfigSnapshot) {
|
||||
return AuthenticationCommands.initiateDesktopHandoff(authCommandTarget(runtimeSnapshot));
|
||||
}
|
||||
|
||||
export async function completeDesktopHandoff({
|
||||
code,
|
||||
token,
|
||||
userId,
|
||||
runtimeSnapshot,
|
||||
}: {
|
||||
code: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
runtimeSnapshot: RuntimeConfigSnapshot;
|
||||
}) {
|
||||
return AuthenticationCommands.completeDesktopHandoff({
|
||||
code,
|
||||
token,
|
||||
userId,
|
||||
target: authCommandTarget(runtimeSnapshot),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -502,7 +502,8 @@
|
||||
}
|
||||
|
||||
.headerContainerThreadPanelOpen {
|
||||
grid-template-columns: minmax(min(12rem, 40%), 1fr) minmax(0, auto);
|
||||
container-type: inline-size;
|
||||
grid-template-columns: minmax(0, max-content) minmax(min-content, 1fr);
|
||||
}
|
||||
|
||||
.headerContainerThreadPanelOpen .topicDivider,
|
||||
@@ -512,6 +513,7 @@
|
||||
|
||||
.headerContainerThreadPanelOpen .messageSearchFocusWrapper {
|
||||
flex: 0 1 15.25rem;
|
||||
width: 9rem;
|
||||
min-width: 9rem;
|
||||
}
|
||||
|
||||
@@ -520,6 +522,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@container (max-width: 32rem) {
|
||||
.headerContainerThreadPanelOpen .messageSearchFocusWrapper {
|
||||
width: var(--channel-header-action-size);
|
||||
min-width: var(--channel-header-action-size);
|
||||
}
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -559,29 +568,11 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.updateIconButtonDisabled {
|
||||
composes: updateIconButton;
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.updateIcon {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.updateProgress {
|
||||
position: absolute;
|
||||
bottom: -0.4rem;
|
||||
right: -0.6rem;
|
||||
background: var(--background-tertiary);
|
||||
color: var(--channel-header-text-primary);
|
||||
font-size: 0.65rem;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 0.0625rem solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.updateIconError {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {getActiveInstanceProductName} from '@app/features/app/state/ActiveInstanceProductName';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import styles from '@app/features/channel/components/InviteEmbed.module.css';
|
||||
import {
|
||||
@@ -364,7 +364,9 @@ const InviteEmbedInner = observer(function InviteEmbedInner({
|
||||
{actionType === GuildInvitePrimaryAction.InvitesDisabled && (
|
||||
<p className={styles.statText} data-flx="channel.invite-embed.stat-text--4">
|
||||
{guildActionState.isRaidDetected
|
||||
? i18n._(DETECTED_A_POTENTIAL_RAID_SO_NEW_USERS_CAN_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
? i18n._(DETECTED_A_POTENTIAL_RAID_SO_NEW_USERS_CAN_DESCRIPTOR, {
|
||||
productName: getActiveInstanceProductName(),
|
||||
})
|
||||
: i18n._(INVITES_ARE_CURRENTLY_PAUSED_FOR_THIS_COMMUNITY_DESCRIPTOR)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {MEMBERS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescript
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import type {UIEvent} from 'react';
|
||||
@@ -16,6 +17,7 @@ interface MemberListContainerProps {
|
||||
channelId: string;
|
||||
identityKey?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
scrollerRef?: React.RefObject<ScrollerHandle | null>;
|
||||
estimatedContentSize?: number | null;
|
||||
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
|
||||
@@ -26,6 +28,7 @@ export const MemberListContainer: React.FC<MemberListContainerProps> = observer(
|
||||
channelId,
|
||||
identityKey,
|
||||
children,
|
||||
className,
|
||||
scrollerRef,
|
||||
estimatedContentSize,
|
||||
onScroll,
|
||||
@@ -43,7 +46,7 @@ export const MemberListContainer: React.FC<MemberListContainerProps> = observer(
|
||||
return (
|
||||
<aside
|
||||
ref={navigationRef}
|
||||
className={styles.memberListContainer}
|
||||
className={clsx(styles.memberListContainer, className)}
|
||||
aria-label={i18n._(MEMBERS_DESCRIPTOR)}
|
||||
data-flx="channel.member-list-container.member-list-container"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {getActiveInstanceProductName} from '@app/features/app/state/ActiveInstanceProductName';
|
||||
import {openClaimAccountModal} from '@app/features/auth/components/modals/ClaimAccountModal';
|
||||
import styles from '@app/features/channel/components/barriers/BarrierComponents.module.css';
|
||||
import {openChannelFollowModal} from '@app/features/channel/components/modals/ChannelFollowModal';
|
||||
@@ -359,7 +359,7 @@ export const SystemDmBarrier = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<BarrierBase
|
||||
message={i18n._(SYSTEM_ANNOUNCEMENTS_FROM_STAFF_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
message={i18n._(SYSTEM_ANNOUNCEMENTS_FROM_STAFF_DESCRIPTOR, {productName: getActiveInstanceProductName()})}
|
||||
icon={
|
||||
<InfoIcon
|
||||
size={remFromPx(18)}
|
||||
|
||||
+30
-180
@@ -1,59 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import Updater, {
|
||||
DOWNLOADING_UPDATE_DESCRIPTOR as DOWNLOADING_UPDATE_STATUS_DESCRIPTOR,
|
||||
} from '@app/features/app/state/Updater';
|
||||
import Updater from '@app/features/app/state/Updater';
|
||||
import styles from '@app/features/channel/components/ChannelHeader.module.css';
|
||||
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowClockwiseIcon, DownloadSimpleIcon} from '@phosphor-icons/react';
|
||||
import {ArrowClockwiseIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useMemo} from 'react';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const INSTALLING_UPDATE_DESCRIPTOR = msg({
|
||||
message: 'Installing desktop update {version}…',
|
||||
const UPDATE_AVAILABLE_DESCRIPTOR = msg({
|
||||
message: 'Update available',
|
||||
comment:
|
||||
'Tooltip on the channel header updater icon while a Fluxer Desktop update is being installed. version is the target version. Trailing horizontal ellipsis is intentional.',
|
||||
});
|
||||
const INSTALLING_UPDATE_2_DESCRIPTOR = msg({
|
||||
message: 'Installing desktop update…',
|
||||
comment:
|
||||
'Tooltip on the channel header updater icon while a Fluxer Desktop update is being installed when version is not known. Trailing horizontal ellipsis is intentional.',
|
||||
});
|
||||
const DOWNLOADING_UPDATE_AT_S_DESCRIPTOR = msg({
|
||||
message: 'Downloading desktop update: {percent}% ({formatBytes} / {formatBytes2} at {formatBytes3}/s)',
|
||||
comment:
|
||||
'Tooltip on the channel header updater icon while downloading an update. Shows percent, bytes downloaded, total bytes, and bytes per second.',
|
||||
});
|
||||
const DOWNLOADING_UPDATE_DESCRIPTOR = msg({
|
||||
message: 'Downloading desktop update {version}: {percent}%',
|
||||
comment: 'Tooltip on the channel header updater icon while downloading a versioned update without throughput info.',
|
||||
});
|
||||
const DOWNLOADING_UPDATE_2_DESCRIPTOR = msg({
|
||||
message: 'Downloading desktop update: {percent}%',
|
||||
comment: 'Tooltip on the channel header updater icon while downloading an update with only percent available.',
|
||||
});
|
||||
const CLICK_TO_DOWNLOAD_UPDATE_DESCRIPTOR = msg({
|
||||
message: 'Download desktop update {version} ({formatBytes})',
|
||||
comment: 'Tooltip on the channel header updater icon prompting download of a versioned update with known size.',
|
||||
});
|
||||
const CLICK_TO_DOWNLOAD_UPDATE_2_DESCRIPTOR = msg({
|
||||
message: 'Download desktop update ({formatBytes})',
|
||||
comment:
|
||||
'Tooltip on the channel header updater icon prompting download of an update of known size but unknown version.',
|
||||
});
|
||||
const CLICK_TO_DOWNLOAD_UPDATE_3_DESCRIPTOR = msg({
|
||||
message: 'Download desktop update {version}',
|
||||
comment: 'Tooltip on the channel header updater icon prompting download of a versioned update with unknown size.',
|
||||
});
|
||||
const CLICK_TO_DOWNLOAD_UPDATE_4_DESCRIPTOR = msg({
|
||||
message: 'Download desktop update',
|
||||
comment: 'Tooltip on the channel header updater icon prompting download of an update with no version or size info.',
|
||||
'Tooltip and accessible label on the channel header updater icon in the desktop app. Clicking it closes the window and opens the updater, which downloads and installs the update.',
|
||||
});
|
||||
const CHOOSE_LINUX_PACKAGE_DESCRIPTOR = msg({
|
||||
message: 'Desktop update {version} available. Choose a Linux package.',
|
||||
@@ -64,21 +26,6 @@ const CHOOSE_LINUX_PACKAGE_2_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Tooltip on the updater icon for a Linux desktop update that requires choosing a package format and no version is known.',
|
||||
});
|
||||
const CLICK_TO_RESTART_AND_INSTALL_UPDATE_DESCRIPTOR = msg({
|
||||
message: 'Restart {productName} to install desktop update {version}',
|
||||
comment:
|
||||
'Tooltip on the channel header updater icon prompting a restart to apply a downloaded versioned update. productName is the app name.',
|
||||
});
|
||||
const CLICK_TO_RESTART_AND_INSTALL_UPDATE_2_DESCRIPTOR = msg({
|
||||
message: 'Restart {productName} to install desktop update',
|
||||
comment:
|
||||
'Tooltip on the channel header updater icon prompting a restart to apply a downloaded update of unknown version. productName is the app name.',
|
||||
});
|
||||
const CLICK_TO_RELOAD_FOR_MODULE_UPDATE_DESCRIPTOR = msg({
|
||||
message: 'Reload {productName} to use the updated modules',
|
||||
comment:
|
||||
'Tooltip on the channel header updater icon prompting a reload to apply freshly downloaded desktop modules. productName is the app name.',
|
||||
});
|
||||
const CLICK_TO_RELOAD_AND_UPDATE_DESCRIPTOR = msg({
|
||||
message: 'Reload {productName} to use web update {version}',
|
||||
comment:
|
||||
@@ -90,151 +37,54 @@ const CLICK_TO_RELOAD_AND_UPDATE_2_DESCRIPTOR = msg({
|
||||
'Tooltip on the channel header updater icon prompting a web reload to apply an update of unknown version. productName is the app name.',
|
||||
});
|
||||
|
||||
function formatBytes(locale: string, bytes: number): string {
|
||||
const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte'] as const;
|
||||
let value = Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return getCachedNumberFormat(locale, {
|
||||
style: 'unit',
|
||||
unit: units[unitIndex],
|
||||
unitDisplay: 'narrow',
|
||||
maximumFractionDigits: value >= 100 || unitIndex === 0 ? 0 : 1,
|
||||
}).format(value);
|
||||
}
|
||||
type UpdaterAffordance = 'desktop' | 'manual' | 'web' | null;
|
||||
|
||||
function resolveUpdaterVisibility(): {
|
||||
readonly hasActionableNativeUpdate: boolean;
|
||||
readonly hasActionableModuleUpdate: boolean;
|
||||
readonly hasActionableWebUpdate: boolean;
|
||||
} {
|
||||
const hasActionableNativeUpdate =
|
||||
Platform.isElectron &&
|
||||
(Updater.nativeUpdateReady ||
|
||||
Updater.nativeAwaitingDownload ||
|
||||
Updater.nativeManualUpdateAvailable ||
|
||||
Updater.nativeDownloadInFlight);
|
||||
const hasActionableModuleUpdate = Platform.isElectron && Updater.moduleUpdateReady && !hasActionableNativeUpdate;
|
||||
const hasActionableWebUpdate =
|
||||
!!Updater.updateInfo.web.available && !hasActionableNativeUpdate && !hasActionableModuleUpdate;
|
||||
return {hasActionableNativeUpdate, hasActionableModuleUpdate, hasActionableWebUpdate};
|
||||
function resolveUpdaterAffordance(): UpdaterAffordance {
|
||||
if (Platform.isElectron && Updater.desktopUpdateAvailable) return 'desktop';
|
||||
if (Platform.isElectron && Updater.nativeManualUpdateAvailable) return 'manual';
|
||||
if (Updater.updateInfo.web.available) return 'web';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isUpdaterIconVisible(): boolean {
|
||||
const {hasActionableNativeUpdate, hasActionableModuleUpdate, hasActionableWebUpdate} = resolveUpdaterVisibility();
|
||||
return hasActionableNativeUpdate || hasActionableModuleUpdate || hasActionableWebUpdate;
|
||||
return resolveUpdaterAffordance() != null;
|
||||
}
|
||||
|
||||
export const UpdaterIcon = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const store = Updater;
|
||||
const {hasActionableNativeUpdate, hasActionableModuleUpdate, hasActionableWebUpdate} = resolveUpdaterVisibility();
|
||||
const tooltip = useMemo(() => {
|
||||
const version = store.displayVersion;
|
||||
if (hasActionableModuleUpdate) {
|
||||
return i18n._(CLICK_TO_RELOAD_FOR_MODULE_UPDATE_DESCRIPTOR, {productName: PRODUCT_NAME});
|
||||
}
|
||||
if (Platform.isElectron && store.updateInfo.native.installing) {
|
||||
return version ? i18n._(INSTALLING_UPDATE_DESCRIPTOR, {version}) : i18n._(INSTALLING_UPDATE_2_DESCRIPTOR);
|
||||
}
|
||||
if (Platform.isElectron && store.nativeDownloadInFlight) {
|
||||
if (!store.nativeDownloadProgressSupported) {
|
||||
return i18n._(DOWNLOADING_UPDATE_STATUS_DESCRIPTOR);
|
||||
}
|
||||
const progress = store.downloadProgress;
|
||||
const percent = getCachedNumberFormat(i18n.locale).format(progress ? Math.round(progress.percent) : 0);
|
||||
if (progress && progress.total > 0 && progress.bytesPerSecond > 0) {
|
||||
return i18n._(DOWNLOADING_UPDATE_AT_S_DESCRIPTOR, {
|
||||
percent,
|
||||
formatBytes: formatBytes(i18n.locale, progress.transferred),
|
||||
formatBytes2: formatBytes(i18n.locale, progress.total),
|
||||
formatBytes3: formatBytes(i18n.locale, progress.bytesPerSecond),
|
||||
});
|
||||
}
|
||||
return version
|
||||
? i18n._(DOWNLOADING_UPDATE_DESCRIPTOR, {version, percent})
|
||||
: i18n._(DOWNLOADING_UPDATE_2_DESCRIPTOR, {percent});
|
||||
}
|
||||
if (Platform.isElectron && (store.nativeAwaitingDownload || store.nativeManualUpdateAvailable)) {
|
||||
if (store.nativeManualUpdateAvailable && store.nativeManualDownloadOptions.length > 0) {
|
||||
return version ? i18n._(CHOOSE_LINUX_PACKAGE_DESCRIPTOR, {version}) : i18n._(CHOOSE_LINUX_PACKAGE_2_DESCRIPTOR);
|
||||
}
|
||||
const size = store.updateInfo.native.downloadSize;
|
||||
if (version && size && size > 0) {
|
||||
return i18n._(CLICK_TO_DOWNLOAD_UPDATE_DESCRIPTOR, {version, formatBytes: formatBytes(i18n.locale, size)});
|
||||
}
|
||||
if (size && size > 0) {
|
||||
return i18n._(CLICK_TO_DOWNLOAD_UPDATE_2_DESCRIPTOR, {formatBytes: formatBytes(i18n.locale, size)});
|
||||
}
|
||||
return version
|
||||
? i18n._(CLICK_TO_DOWNLOAD_UPDATE_3_DESCRIPTOR, {version})
|
||||
: i18n._(CLICK_TO_DOWNLOAD_UPDATE_4_DESCRIPTOR);
|
||||
}
|
||||
if (Platform.isElectron && store.nativeUpdateReady) {
|
||||
return version
|
||||
? i18n._(CLICK_TO_RESTART_AND_INSTALL_UPDATE_DESCRIPTOR, {version, productName: PRODUCT_NAME})
|
||||
: i18n._(CLICK_TO_RESTART_AND_INSTALL_UPDATE_2_DESCRIPTOR, {productName: PRODUCT_NAME});
|
||||
}
|
||||
return version
|
||||
? i18n._(CLICK_TO_RELOAD_AND_UPDATE_DESCRIPTOR, {version, productName: PRODUCT_NAME})
|
||||
: i18n._(CLICK_TO_RELOAD_AND_UPDATE_2_DESCRIPTOR, {productName: PRODUCT_NAME});
|
||||
}, [
|
||||
hasActionableModuleUpdate,
|
||||
store.displayVersion,
|
||||
store.downloadProgress,
|
||||
store.nativeAwaitingDownload,
|
||||
store.nativeDownloadInFlight,
|
||||
store.nativeDownloadProgressSupported,
|
||||
store.nativeManualDownloadOptions,
|
||||
store.nativeManualUpdateAvailable,
|
||||
store.nativeUpdateReady,
|
||||
store.updateInfo.native.downloadSize,
|
||||
store.updateInfo.native.installing,
|
||||
i18n.locale,
|
||||
]);
|
||||
const handleClick = useCallback(() => {
|
||||
void store.applyUpdate();
|
||||
}, [store]);
|
||||
if (!hasActionableNativeUpdate && !hasActionableModuleUpdate && !hasActionableWebUpdate) {
|
||||
void Updater.applyUpdate();
|
||||
}, []);
|
||||
const affordance = resolveUpdaterAffordance();
|
||||
if (affordance == null) {
|
||||
return null;
|
||||
}
|
||||
const isInstalling = Platform.isElectron && store.updateInfo.native.installing;
|
||||
const isDownloading = Platform.isElectron && store.nativeDownloadInFlight;
|
||||
const buttonClass = isInstalling ? styles.updateIconButtonDisabled : styles.updateIconButton;
|
||||
const Icon = hasActionableWebUpdate || hasActionableModuleUpdate ? ArrowClockwiseIcon : DownloadSimpleIcon;
|
||||
const percentLabel =
|
||||
isDownloading && store.nativeDownloadProgressSupported
|
||||
? getCachedNumberFormat(i18n.locale, {style: 'percent', maximumFractionDigits: 0}).format(
|
||||
(store.downloadProgress?.percent ?? 0) / 100,
|
||||
)
|
||||
: null;
|
||||
const version = Updater.displayVersion;
|
||||
let tooltip: string;
|
||||
if (affordance === 'manual' && Updater.nativeManualDownloadOptions.length > 0) {
|
||||
tooltip = version ? i18n._(CHOOSE_LINUX_PACKAGE_DESCRIPTOR, {version}) : i18n._(CHOOSE_LINUX_PACKAGE_2_DESCRIPTOR);
|
||||
} else if (affordance !== 'web') {
|
||||
tooltip = i18n._(UPDATE_AVAILABLE_DESCRIPTOR);
|
||||
} else {
|
||||
tooltip = version
|
||||
? i18n._(CLICK_TO_RELOAD_AND_UPDATE_DESCRIPTOR, {version, productName: PRODUCT_NAME})
|
||||
: i18n._(CLICK_TO_RELOAD_AND_UPDATE_2_DESCRIPTOR, {productName: PRODUCT_NAME});
|
||||
}
|
||||
return (
|
||||
<Tooltip text={tooltip} position="bottom" data-flx="channel.channel-header-components.updater-icon.tooltip">
|
||||
<FocusRing offset={-2} data-flx="channel.channel-header-components.updater-icon.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
className={styles.updateIconButton}
|
||||
onClick={handleClick}
|
||||
aria-label={tooltip}
|
||||
aria-disabled={isInstalling || undefined}
|
||||
data-flx="channel.channel-header-components.updater-icon.button.click"
|
||||
>
|
||||
<Icon
|
||||
<ArrowClockwiseIcon
|
||||
weight="bold"
|
||||
className={styles.updateIcon}
|
||||
data-flx="channel.channel-header-components.updater-icon.update-icon"
|
||||
/>
|
||||
{percentLabel != null && (
|
||||
<span
|
||||
className={styles.updateProgress}
|
||||
data-flx="channel.channel-header-components.updater-icon.update-progress"
|
||||
>
|
||||
{percentLabel}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
|
||||
-31
@@ -73,26 +73,6 @@ const MEDIA_LOADING_STATE_DESCRIPTOR = msg({
|
||||
message: 'Media loading state',
|
||||
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
|
||||
});
|
||||
const ANY_UPDATER_READY_DESCRIPTOR = msg({
|
||||
message: 'Any updater ready',
|
||||
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
|
||||
});
|
||||
const NATIVE_UPDATER_READY_DESCRIPTOR = msg({
|
||||
message: 'Native updater ready',
|
||||
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
|
||||
});
|
||||
const NATIVE_UPDATE_PROGRESS_DESCRIPTOR = msg({
|
||||
message: 'Native update progress',
|
||||
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
|
||||
});
|
||||
const WEB_UPDATER_READY_DESCRIPTOR = msg({
|
||||
message: 'Web updater ready',
|
||||
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
|
||||
});
|
||||
const UPDATER_STATE_DESCRIPTOR = msg({
|
||||
message: 'Updater state',
|
||||
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
|
||||
});
|
||||
const SHOW_MYSELF_TYPING_DESCRIPTOR = msg({
|
||||
message: 'Show myself typing',
|
||||
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
|
||||
@@ -297,7 +277,6 @@ const formatDeveloperOptionValue = <K extends keyof DeveloperOptionsState>(
|
||||
case 'mockMatureContentGateReason':
|
||||
case 'mockMatureMediaGateReason':
|
||||
case 'mockTitlebarPlatformOverride':
|
||||
case 'mockUpdaterState':
|
||||
return String(value).replace(/_/g, ' ');
|
||||
case 'premiumSinceOverride':
|
||||
case 'premiumUntilOverride':
|
||||
@@ -342,16 +321,6 @@ export const getDeveloperOptionLabel = (key: keyof DeveloperOptionsState): Messa
|
||||
return EMBED_SKELETONS_DESCRIPTOR;
|
||||
case 'forceMediaLoading':
|
||||
return MEDIA_LOADING_STATE_DESCRIPTOR;
|
||||
case 'forceUpdateReady':
|
||||
return ANY_UPDATER_READY_DESCRIPTOR;
|
||||
case 'forceNativeUpdateReady':
|
||||
return NATIVE_UPDATER_READY_DESCRIPTOR;
|
||||
case 'mockNativeUpdateProgress':
|
||||
return NATIVE_UPDATE_PROGRESS_DESCRIPTOR;
|
||||
case 'forceWebUpdateReady':
|
||||
return WEB_UPDATER_READY_DESCRIPTOR;
|
||||
case 'mockUpdaterState':
|
||||
return UPDATER_STATE_DESCRIPTOR;
|
||||
case 'showMyselfTyping':
|
||||
return SHOW_MYSELF_TYPING_DESCRIPTOR;
|
||||
case 'slowAttachmentUpload':
|
||||
|
||||
-5
@@ -12,11 +12,6 @@ export const DEFAULT_DEVELOPER_OPTIONS = {
|
||||
forceRenderPlaceholders: false,
|
||||
forceEmbedSkeletons: false,
|
||||
forceMediaLoading: false,
|
||||
forceUpdateReady: false,
|
||||
forceNativeUpdateReady: false,
|
||||
mockNativeUpdateProgress: null,
|
||||
forceWebUpdateReady: false,
|
||||
mockUpdaterState: 'none',
|
||||
showMyselfTyping: false,
|
||||
slowAttachmentUpload: false,
|
||||
slowMessageLoad: false,
|
||||
|
||||
@@ -366,7 +366,6 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
|
||||
onClick={handleClick}
|
||||
onMouseEnter={scheduleViewerWarm}
|
||||
onMouseLeave={cancelViewerWarm}
|
||||
onMouseDown={openInBrowser.onMouseDown}
|
||||
onAuxClick={openInBrowser.onAuxClick}
|
||||
onKeyDown={handleClick}
|
||||
aria-label={ariaLabel}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@app/features/channel/components/embeds/channel_embed/ChannelEmbedShared';
|
||||
import {SUPPRESS_EMBEDS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||
import {useOpenInBrowserOnMiddleClick} from '@app/features/messaging/hooks/useOpenInBrowserOnMiddleClick';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import TrustedDomain from '@app/features/trusted_domain/state/TrustedDomain';
|
||||
@@ -31,6 +32,7 @@ export const EmbedLink: FC<LinkComponentProps> = observer(({url, children, class
|
||||
logger.warn('Invalid URL in embed link:', url);
|
||||
}
|
||||
};
|
||||
const openInBrowser = useOpenInBrowserOnMiddleClick(url);
|
||||
return (
|
||||
<FocusRing data-flx="channel.embeds.embed.link-component.focus-ring">
|
||||
<a
|
||||
@@ -39,6 +41,7 @@ export const EmbedLink: FC<LinkComponentProps> = observer(({url, children, class
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
onClick={handleClick}
|
||||
onAuxClick={openInBrowser.onAuxClick}
|
||||
data-flx="channel.embeds.embed.link-component.a"
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -343,7 +343,6 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
className={styles.imagePreviewHandler}
|
||||
aria-label={ariaLabel}
|
||||
onClick={openImagePreview}
|
||||
onMouseDown={openInBrowser.onMouseDown}
|
||||
onAuxClick={openInBrowser.onAuxClick}
|
||||
onKeyDown={openImagePreview}
|
||||
onMouseEnter={onViewerWarmEnter}
|
||||
|
||||
@@ -233,7 +233,6 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
className={styles.imagePreviewHandler}
|
||||
aria-label={i18n._(OPEN_IMAGE_IN_FULL_VIEW_DESCRIPTOR)}
|
||||
onClick={openImagePreview}
|
||||
onMouseDown={openInBrowser.onMouseDown}
|
||||
onAuxClick={openInBrowser.onAuxClick}
|
||||
onKeyDown={openImagePreview}
|
||||
onMouseEnter={onViewerWarmEnter}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {getActiveInstanceProductName} from '@app/features/app/state/ActiveInstanceProductName';
|
||||
import styles from '@app/features/channel/components/GuildMembersPage.module.css';
|
||||
import {ROLES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
@@ -105,7 +105,7 @@ export function MembersTableHeaderRow({
|
||||
data-flx="channel.guild-members-page.members-table-view.header-cell--3"
|
||||
>
|
||||
<div className={styles.thContent} data-flx="channel.guild-members-page.members-table-view.th-content--2">
|
||||
{i18n._(JOINED_PRODUCT_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
{i18n._(JOINED_PRODUCT_DESCRIPTOR, {productName: getActiveInstanceProductName()})}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.filterButton, joinedFluxerActive && styles.filterButtonActive)}
|
||||
|
||||
@@ -140,12 +140,6 @@ export const TextareaContextMenu = observer(
|
||||
}
|
||||
targetElement.focus({preventScroll: true});
|
||||
};
|
||||
const handleReplaceMisspelling = async (suggestion: string) => {
|
||||
if (electronAPI?.spellcheckReplaceMisspelling) {
|
||||
await electronAPI.spellcheckReplaceMisspelling(suggestion);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
const handleAddToDictionary = async () => {
|
||||
if (!misspelledWord) return;
|
||||
Spellcheck.addPersonalWord(misspelledWord);
|
||||
@@ -167,6 +161,11 @@ export const TextareaContextMenu = observer(
|
||||
requestAnimationFrame(action);
|
||||
});
|
||||
};
|
||||
const handleReplaceMisspelling = (suggestion: string) => {
|
||||
runAfterClose(() => {
|
||||
void electronAPI?.spellcheckReplaceMisspelling?.(suggestion);
|
||||
});
|
||||
};
|
||||
const execCommand = (command: string) => {
|
||||
runAfterClose(() => {
|
||||
document.execCommand(command);
|
||||
@@ -217,6 +216,7 @@ export const TextareaContextMenu = observer(
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
const isPasswordTarget = targetElement instanceof HTMLInputElement && targetElement.type === 'password';
|
||||
const spellcheckEnabled = Spellcheck.enabled;
|
||||
const hasMisspelling = spellcheckEnabled && misspelledWord && suggestions.length > 0;
|
||||
return (
|
||||
@@ -307,19 +307,21 @@ export const TextareaContextMenu = observer(
|
||||
{i18n._(SELECT_ALL_DESCRIPTOR)}
|
||||
</MenuItem>
|
||||
</MenuGroup>
|
||||
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group--5">
|
||||
<MenuItemSubmenu
|
||||
label={i18n._(SPELLCHECK_DESCRIPTOR)}
|
||||
render={() => (
|
||||
<SpellcheckSubmenu
|
||||
isElectron={isElectron()}
|
||||
onOpenSpellcheckSettings={handleOpenSpellcheckSettings}
|
||||
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu"
|
||||
/>
|
||||
)}
|
||||
data-flx="channel.textarea.textarea-context-menu.menu-item-submenu"
|
||||
/>
|
||||
</MenuGroup>
|
||||
{!isPasswordTarget && (
|
||||
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group--5">
|
||||
<MenuItemSubmenu
|
||||
label={i18n._(SPELLCHECK_DESCRIPTOR)}
|
||||
render={() => (
|
||||
<SpellcheckSubmenu
|
||||
isElectron={isElectron()}
|
||||
onOpenSpellcheckSettings={handleOpenSpellcheckSettings}
|
||||
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu"
|
||||
/>
|
||||
)}
|
||||
data-flx="channel.textarea.textarea-context-menu.menu-item-submenu"
|
||||
/>
|
||||
</MenuGroup>
|
||||
)}
|
||||
{showSendButtonToggle && (
|
||||
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group--6">
|
||||
<CheckboxItem
|
||||
|
||||
@@ -56,10 +56,6 @@ const FORCE_LOADING_SKELETON_DESCRIPTOR = msg({
|
||||
message: 'Force loading skeleton',
|
||||
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
|
||||
});
|
||||
const FORCE_UPDATE_READY_DESCRIPTOR = msg({
|
||||
message: 'Force update ready',
|
||||
comment: 'Developer option label for simulating an available update.',
|
||||
});
|
||||
const SHOW_MYSELF_TYPING_DESCRIPTOR = msg({
|
||||
message: 'Show myself typing',
|
||||
comment: 'Developer option label for showing the current user as typing.',
|
||||
@@ -161,10 +157,6 @@ export const getToggleGroups = (): Array<ToggleGroup> => [
|
||||
items: [
|
||||
{key: 'bypassLoadingSkeleton', label: BYPASS_LOADING_SKELETON_DESCRIPTOR},
|
||||
{key: 'forceLoadingSkeleton', label: FORCE_LOADING_SKELETON_DESCRIPTOR},
|
||||
{
|
||||
key: 'forceUpdateReady',
|
||||
label: FORCE_UPDATE_READY_DESCRIPTOR,
|
||||
},
|
||||
{
|
||||
key: 'showMyselfTyping',
|
||||
label: SHOW_MYSELF_TYPING_DESCRIPTOR,
|
||||
|
||||
@@ -66,6 +66,10 @@ const TERMS_ACCEPTANCE_NAGBAR_DESCRIPTOR = msg({
|
||||
message: 'Terms acceptance nagbar',
|
||||
comment: 'Developer control label for the terms-acceptance banner.',
|
||||
});
|
||||
const PRIVACY_SETUP_NAGBAR_DESCRIPTOR = msg({
|
||||
message: 'Privacy setup nagbar',
|
||||
comment: 'Developer control label for the privacy setup banner.',
|
||||
});
|
||||
const VOICE_SESSION_RESTORE_NAGBAR_DESCRIPTOR = msg({
|
||||
message: 'Voice session restore nagbar',
|
||||
comment: 'Developer control label for the voice-session-restore banner.',
|
||||
@@ -301,6 +305,18 @@ export const getNagbarControls = (): Array<NagbarControlDefinition> => [
|
||||
forceShowDisabled: (state) => state.forceTermsAcceptance,
|
||||
forceHideDisabled: (state) => state.forceHideTermsAcceptance,
|
||||
},
|
||||
{
|
||||
key: 'forcePrivacySetup',
|
||||
label: PRIVACY_SETUP_NAGBAR_DESCRIPTOR,
|
||||
forceKey: 'forcePrivacySetup',
|
||||
forceHideKey: 'forceHidePrivacySetup',
|
||||
resetKeys: ['forcePrivacySetup'],
|
||||
status: (state) =>
|
||||
state.forcePrivacySetup ? FORCE_ENABLED : state.forceHidePrivacySetup ? FORCE_DISABLED : USING_ACTUAL_STATE,
|
||||
useActualDisabled: (state) => !state.forcePrivacySetup && !state.forceHidePrivacySetup,
|
||||
forceShowDisabled: (state) => state.forcePrivacySetup,
|
||||
forceHideDisabled: (state) => state.forceHidePrivacySetup,
|
||||
},
|
||||
{
|
||||
key: 'forceDesktopDownload',
|
||||
label: DESKTOP_DOWNLOAD_NAGBAR_DESCRIPTOR,
|
||||
|
||||
@@ -28,11 +28,6 @@ export type DeveloperOptionsState = Readonly<{
|
||||
forceRenderPlaceholders: boolean;
|
||||
forceEmbedSkeletons: boolean;
|
||||
forceMediaLoading: boolean;
|
||||
forceUpdateReady: boolean;
|
||||
forceNativeUpdateReady: boolean;
|
||||
mockNativeUpdateProgress: number | null;
|
||||
forceWebUpdateReady: boolean;
|
||||
mockUpdaterState: 'none' | 'checking' | 'available' | 'downloading' | 'ready' | 'installing' | 'error';
|
||||
showMyselfTyping: boolean;
|
||||
slowAttachmentUpload: boolean;
|
||||
slowMessageLoad: boolean;
|
||||
@@ -103,11 +98,6 @@ class DeveloperOptions implements DeveloperOptionsState {
|
||||
forceRenderPlaceholders = false;
|
||||
forceEmbedSkeletons = false;
|
||||
forceMediaLoading = false;
|
||||
forceUpdateReady = false;
|
||||
forceNativeUpdateReady = false;
|
||||
mockNativeUpdateProgress: number | null = null;
|
||||
forceWebUpdateReady = false;
|
||||
mockUpdaterState: DeveloperOptionsState['mockUpdaterState'] = 'none';
|
||||
showMyselfTyping = false;
|
||||
slowAttachmentUpload = false;
|
||||
slowMessageLoad = false;
|
||||
@@ -181,11 +171,6 @@ class DeveloperOptions implements DeveloperOptionsState {
|
||||
'forceRenderPlaceholders',
|
||||
'forceEmbedSkeletons',
|
||||
'forceMediaLoading',
|
||||
'forceUpdateReady',
|
||||
'forceNativeUpdateReady',
|
||||
'mockNativeUpdateProgress',
|
||||
'forceWebUpdateReady',
|
||||
'mockUpdaterState',
|
||||
'showMyselfTyping',
|
||||
'slowAttachmentUpload',
|
||||
'slowMessageLoad',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user