+ Entries that resolve to the same platform user, or that lost
+ their virtual phone to another entry. Nothing is deleted until
+ you confirm the list.
+
)
}
+/**
+ * Confirmation for one or more platform entries. The description comes from the
+ * backend, so the dialog names the exact entries and how many Telegram clients
+ * are signed in through them before anything is removed.
+ */
+function DeleteAccounts(props: {
+ data: PlatformAccountDashboardData
+ platformIds: string[]
+ onClose: () => void
+ onDeleted: () => Promise
+}) {
+ const [preview, setPreview] =
+ createSignal()
+ const load = useAction(),
+ remove = useAction()
+ void load.run(async () => {
+ setPreview(await props.data.describeAccountRemoval(props.platformIds))
+ })
+ const unmanaged = () =>
+ (preview()?.targets ?? []).filter((target) => !target.managed)
+ const accounts = () => preview()?.targets ?? []
+ return (
+
+
+ The platform entries below are removed from the configuration, together
+ with their virtual phone, login code and two-step verification password.
+ Their message history stays in the database.
+
+ {preview()!.clientAuthorizations} Telegram client
+ {preview()!.clientAuthorizations > 1 ? 's' : ''} signed in through
+ the selected entries will be signed out.
+
+
+
+
+ {unmanaged()
+ .map((target) => target.platformId)
+ .join(', ')}{' '}
+ {unmanaged().length > 1 ? 'are' : 'is'} not managed by the
+ configuration file. Disable the plugin entry instead.
+
+
+
+
+
+
+
+
+ )
+}
+
function PasswordModal(props: {
hasPassword: boolean
onClose: () => void
diff --git a/packages/bridge/client/bridge-model.test.ts b/packages/bridge/client/bridge-model.test.ts
index 805acf2..93f4556 100644
--- a/packages/bridge/client/bridge-model.test.ts
+++ b/packages/bridge/client/bridge-model.test.ts
@@ -1,12 +1,21 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
+import type {
+ CrossGramServerConfig,
+ PlatformAccountDuplicateGroup,
+} from '../src/dashboard-types.js'
import {
botLink,
copyText,
+ describeDuplicateGroup,
+ duplicateOwners,
+ duplicatePlatformIds,
+ formatEndpoint,
formatPhone,
parseTelegramLoginUrl,
remainingSeconds,
safeImageURL,
sameOriginPath,
+ withServerEndpoint,
} from './bridge-model.js'
afterEach(() => vi.restoreAllMocks())
describe('bridge dashboard input boundaries', () => {
@@ -53,3 +62,87 @@ describe('bridge dashboard input boundaries', () => {
await expect(copyText('credential')).rejects.toThrow('permission denied')
})
})
+describe('copied configuration endpoints', () => {
+ const config: CrossGramServerConfig = {
+ name: 'CrossGram',
+ enable_special_config: false,
+ host: '203.0.113.8',
+ port: 4430,
+ rsa_key: 'PUBLIC_KEY',
+ dcs: [
+ { id: 1, ip: '203.0.113.8', port: 4430 },
+ { id: 2, ip: '203.0.113.8', port: 4430 },
+ ],
+ }
+ it('formats endpoints the way a client configuration spells them', () => {
+ expect(formatEndpoint({ host: '203.0.113.8', port: 4430 })).toBe(
+ '203.0.113.8:4430',
+ )
+ expect(formatEndpoint({ host: '2001:db8::1', port: 8443 })).toBe(
+ '[2001:db8::1]:8443',
+ )
+ })
+ it('rewrites only the host and port of a copied document', () => {
+ const rewritten = withServerEndpoint(config, {
+ host: 'backup.example.test',
+ port: 8443,
+ primary: false,
+ })
+ expect(rewritten).toEqual({
+ ...config,
+ host: 'backup.example.test',
+ port: 8443,
+ dcs: [
+ { id: 1, ip: 'backup.example.test', port: 8443 },
+ { id: 2, ip: 'backup.example.test', port: 8443 },
+ ],
+ })
+ // The copied document keeps the page's readable layout, only the address differs.
+ expect(JSON.stringify(rewritten, null, 2)).toContain('"host": "backup.example.test"')
+ // Selecting the primary endpoint leaves the document exactly as configured.
+ expect(
+ withServerEndpoint(config, {
+ host: '203.0.113.8',
+ port: 4430,
+ primary: true,
+ }),
+ ).toEqual(config)
+ })
+})
+
+describe('duplicate account presentation', () => {
+ const groups: PlatformAccountDuplicateGroup[] = [
+ {
+ keep: 'qqnt',
+ remove: ['qqnt-2', 'qqnt-3'],
+ reason: 'virtual-phone',
+ },
+ { keep: 'matrix', remove: ['matrix-2'], reason: 'identity' },
+ ]
+ it('maps every duplicated entry to the entry that keeps the account', () => {
+ expect([...duplicateOwners(groups)]).toEqual([
+ ['qqnt-2', 'qqnt'],
+ ['qqnt-3', 'qqnt'],
+ ['matrix-2', 'matrix'],
+ ])
+ expect(duplicateOwners([]).size).toBe(0)
+ })
+ it('lists duplicated entries once, in a stable order', () => {
+ expect(duplicatePlatformIds(groups)).toEqual([
+ 'matrix-2',
+ 'qqnt-2',
+ 'qqnt-3',
+ ])
+ expect(
+ duplicatePlatformIds([...groups, { keep: 'other', remove: ['qqnt-2'], reason: 'identity' }]),
+ ).toEqual(['matrix-2', 'qqnt-2', 'qqnt-3'])
+ })
+ it('states why a group counts as duplicated', () => {
+ expect(describeDuplicateGroup(groups[0]!)).toBe(
+ 'qqnt-2, qqnt-3 duplicate qqnt (already serves the same virtual phone)',
+ )
+ expect(describeDuplicateGroup(groups[1]!)).toBe(
+ 'matrix-2 duplicates matrix (resolves to the same platform user)',
+ )
+ })
+})
diff --git a/packages/bridge/client/bridge-model.ts b/packages/bridge/client/bridge-model.ts
index 56f4af5..edcb5fb 100644
--- a/packages/bridge/client/bridge-model.ts
+++ b/packages/bridge/client/bridge-model.ts
@@ -1,3 +1,8 @@
+import type {
+ CrossGramServerConfig,
+ PlatformAccountDuplicateGroup,
+ PlatformAccountServerEndpoint,
+} from '../src/dashboard-types.js'
export function parseTelegramLoginUrl(value: string): string | undefined {
try {
const url = new URL(value),
@@ -60,5 +65,61 @@ export function formatPhone(value?: string): string {
return '+888 ' + digits.slice(3).replace(/(\d)(?=(\d{3})+$)/g, '$1 ')
return '+' + digits.replace(/(\d)(?=(\d{3})+$)/g, '$1 ')
}
+/** `host:port`, bracketing IPv6 hosts the way a client configuration spells them. */
+export function formatEndpoint(endpoint: { host: string; port: number }): string {
+ return (
+ (endpoint.host.includes(':') ? '[' + endpoint.host + ']' : endpoint.host) +
+ ':' +
+ endpoint.port
+ )
+}
+/** Point a copy of the server configuration at one advertised endpoint. */
+export function withServerEndpoint(
+ config: CrossGramServerConfig,
+ endpoint: PlatformAccountServerEndpoint,
+): CrossGramServerConfig {
+ return {
+ ...config,
+ host: endpoint.host,
+ port: endpoint.port,
+ dcs: config.dcs.map((dc) => ({ ...dc, ip: endpoint.host, port: endpoint.port })),
+ }
+}
+/**
+ * Entry each duplicated platform entry duplicates. The backend only reports
+ * entries it can prove are duplicates, so the map is safe to render directly.
+ */
+export function duplicateOwners(
+ groups: readonly PlatformAccountDuplicateGroup[],
+): Map {
+ const owners = new Map()
+ for (const group of groups)
+ for (const platformId of group.remove)
+ if (!owners.has(platformId)) owners.set(platformId, group.keep)
+ return owners
+}
+/** Every entry that duplicates another one, in a stable order. */
+export function duplicatePlatformIds(
+ groups: readonly PlatformAccountDuplicateGroup[],
+): string[] {
+ return [...new Set(groups.flatMap((group) => group.remove))].sort()
+}
+/** Describe one duplicate group for the cleanup banner. */
+export function describeDuplicateGroup(
+ group: PlatformAccountDuplicateGroup,
+): string {
+ const reason =
+ group.reason === 'virtual-phone'
+ ? 'already serves the same virtual phone'
+ : 'resolves to the same platform user'
+ return (
+ group.remove.join(', ') +
+ (group.remove.length > 1 ? ' duplicate ' : ' duplicates ') +
+ group.keep +
+ ' (' +
+ reason +
+ ')'
+ )
+}
export { sameOriginPath, copyText } from "cordis-webui-solidjs/utils"
diff --git a/packages/bridge/client/style.css b/packages/bridge/client/style.css
index d97e413..679901f 100644
--- a/packages/bridge/client/style.css
+++ b/packages/bridge/client/style.css
@@ -29,6 +29,77 @@
margin: 20px 0;
max-width: 480px;
}
+ .connection-config-actions {
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ flex-wrap: wrap;
+ }
+ .endpoint-picker {
+ min-width: 0;
+ }
+ .endpoint-picker select {
+ max-width: 260px;
+ }
+ .account-duplicates {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 16px;
+ border-color: var(--tertiary);
+ }
+ .account-duplicates strong {
+ font-size: 15px;
+ }
+ .account-duplicates p {
+ margin-top: 6px;
+ font-size: 12.5px;
+ opacity: 0.85;
+ }
+ .account-duplicates ul {
+ margin: 8px 0 0;
+ padding-left: 18px;
+ font-size: 12px;
+ overflow-wrap: anywhere;
+ }
+ .account-selection {
+ margin-bottom: 16px;
+ }
+ .identity-card.selected {
+ outline: 2px solid var(--primary);
+ outline-offset: -1px;
+ }
+ .identity-card > header {
+ gap: 12px;
+ }
+ .account-select {
+ display: flex;
+ align-items: center;
+ flex: none;
+ }
+ .account-select input {
+ width: 18px;
+ height: 18px;
+ }
+ .identity-duplicate {
+ display: flex;
+ }
+ .chip.duplicate {
+ background: var(--tertiary-container);
+ color: var(--on-tertiary-container);
+ }
+ .identity-actions {
+ margin-top: auto;
+ }
+ .removal-targets {
+ margin: 0;
+ padding-left: 18px;
+ display: grid;
+ gap: 6px;
+ font-size: 13px;
+ overflow-wrap: anywhere;
+ }
.platform-account-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 350px), 1fr));
diff --git a/packages/bridge/src/account-dashboard.test.ts b/packages/bridge/src/account-dashboard.test.ts
index c95dba3..d7be195 100644
--- a/packages/bridge/src/account-dashboard.test.ts
+++ b/packages/bridge/src/account-dashboard.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { ProvisionedPlatformAccount } from './platform-account.js'
import {
- makeCrossGramServerConfig, makePlatformAccountView, makeUnavailableAccountView,
+ makeCrossGramServerConfig, makePlatformAccountView, makeServerEndpoints, makeUnavailableAccountView,
} from './account-dashboard.js'
const provisioned: ProvisionedPlatformAccount = {
@@ -60,4 +60,19 @@ describe('platform account dashboard projection', () => {
/altEndpoints|privateKey|rsaKeyPath|token|credentials|totp/i,
)
})
+
+ it('offers the advertised endpoints for copying, main first and without duplicates', () => {
+ expect(makeServerEndpoints('203.0.113.8', 4430, [
+ 'backup.example:8443',
+ '203.0.113.8:4430',
+ '[2001:db8::1]:4430',
+ ])).toEqual([
+ { host: '203.0.113.8', port: 4430, primary: true },
+ { host: 'backup.example', port: 8443, primary: false },
+ { host: '2001:db8::1', port: 4430, primary: false },
+ ])
+ expect(makeServerEndpoints('203.0.113.8', 4430)).toEqual([
+ { host: '203.0.113.8', port: 4430, primary: true },
+ ])
+ })
})
diff --git a/packages/bridge/src/account-dashboard.ts b/packages/bridge/src/account-dashboard.ts
index 213db92..ec11504 100644
--- a/packages/bridge/src/account-dashboard.ts
+++ b/packages/bridge/src/account-dashboard.ts
@@ -1,9 +1,10 @@
import type { ProvisionedPlatformAccount } from './platform-account.js'
import { getLoginCodeState } from './login-code.js'
import { isValidSrpVerifier } from './login-srp.js'
+import { parseEndpoint } from './synthetic.js'
-import type { CrossGramServerConfig, PlatformAccountStatus, PlatformAccountView } from './dashboard-types.js'
-export type { CrossGramServerConfig, CrossGramServerConfigDc, PlatformAccountDashboardData, PlatformAccountStatus, PlatformAccountView } from './dashboard-types.js'
+import type { CrossGramServerConfig, PlatformAccountServerEndpoint, PlatformAccountStatus, PlatformAccountView } from './dashboard-types.js'
+export type { CrossGramServerConfig, CrossGramServerConfigDc, PlatformAccountDashboardData, PlatformAccountStatus, PlatformAccountServerEndpoint, PlatformAccountView } from './dashboard-types.js'
export function makeCrossGramServerConfig(
host: string,
@@ -20,6 +21,29 @@ export function makeCrossGramServerConfig(
}
}
+/**
+ * Endpoints a copied configuration can point at: the configured main endpoint
+ * first, then every advertised alternative in configuration order. The list only
+ * describes where a client may connect; it never changes what the bridge
+ * advertises to clients through `help.getConfig`.
+ */
+export function makeServerEndpoints(
+ host: string,
+ port: number,
+ altEndpoints: readonly string[] = [],
+): PlatformAccountServerEndpoint[] {
+ const endpoints: PlatformAccountServerEndpoint[] = [{ host, port, primary: true }]
+ const seen = new Set([`${host}:${port}`])
+ for (const endpoint of altEndpoints) {
+ const parsed = parseEndpoint(endpoint)
+ const key = `${parsed.host}:${parsed.port}`
+ if (seen.has(key)) continue
+ seen.add(key)
+ endpoints.push({ ...parsed, primary: false })
+ }
+ return endpoints
+}
+
export function makePlatformAccountView(
platformId: string,
platformKind: string,
diff --git a/packages/bridge/src/account-duplicates.test.ts b/packages/bridge/src/account-duplicates.test.ts
new file mode 100644
index 0000000..fdd5821
--- /dev/null
+++ b/packages/bridge/src/account-duplicates.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest'
+import { findDuplicateAccounts, type PlatformAccountDuplicateCandidate } from './account-duplicates.js'
+
+const candidate = (
+ platformId: string,
+ values: Partial = {},
+): PlatformAccountDuplicateCandidate => ({
+ platformId,
+ platformKind: 'qq',
+ clientAuthorizations: 0,
+ ...values,
+})
+
+describe('platform account duplicate detection', () => {
+ it('groups entries that resolve to the same platform user and keeps the entry clients use', () => {
+ expect(findDuplicateAccounts([
+ candidate('qq-one', { userId: 'qq-10001', clientAuthorizations: 1 }),
+ candidate('qq-two', { userId: 'qq-10001', clientAuthorizations: 3 }),
+ candidate('qq-other', { userId: 'qq-10002' }),
+ candidate('matrix', { platformKind: 'matrix', userId: 'qq-10001' }),
+ ])).toEqual([
+ { keep: 'qq-two', remove: ['qq-one'], reason: 'identity' },
+ ])
+ })
+
+ it('prefers the stable entry id order when no entry is signed in', () => {
+ expect(findDuplicateAccounts([
+ candidate('qq-b', { userId: 'uid' }),
+ candidate('qq-a', { userId: 'uid' }),
+ ])).toEqual([{ keep: 'qq-a', remove: ['qq-b'], reason: 'identity' }])
+ })
+
+ it('reports an entry that lost its virtual phone to the entry already serving the account', () => {
+ expect(findDuplicateAccounts([
+ candidate('qqnt', { userId: 'uid', clientAuthorizations: 2 }),
+ candidate('qqnt-2', { claimedBy: 'qqnt' }),
+ candidate('qqnt-3', { claimedBy: 'qqnt' }),
+ ])).toEqual([
+ {
+ keep: 'qqnt',
+ remove: ['qqnt-2', 'qqnt-3'],
+ reason: 'virtual-phone',
+ },
+ ])
+ })
+
+ it('never reports an entry twice or invents duplicates from unrelated failures', () => {
+ expect(findDuplicateAccounts([
+ candidate('qqnt', { userId: 'uid' }),
+ // The same user id, but the phone claim already explains the entry.
+ candidate('qqnt-2', { userId: 'uid', claimedBy: 'qqnt' }),
+ // Adapter failures without an identity are not duplicates.
+ candidate('offline', { platformKind: 'discord' }),
+ candidate('unsupported', { platformKind: 'wechat' }),
+ // A claim from an entry that is no longer registered proves nothing.
+ candidate('stale', { claimedBy: 'removed-entry' }),
+ ])).toEqual([
+ { keep: 'qqnt', remove: ['qqnt-2'], reason: 'virtual-phone' },
+ ])
+ })
+
+ it('reports nothing when every account is distinct', () => {
+ expect(findDuplicateAccounts([
+ candidate('qq-one', { userId: 'uid-1' }),
+ candidate('qq-two', { userId: 'uid-2' }),
+ candidate('offline'),
+ ])).toEqual([])
+ })
+})
diff --git a/packages/bridge/src/account-duplicates.ts b/packages/bridge/src/account-duplicates.ts
new file mode 100644
index 0000000..0be1712
--- /dev/null
+++ b/packages/bridge/src/account-duplicates.ts
@@ -0,0 +1,82 @@
+import type { PlatformAccountDuplicateGroup } from './dashboard-types.js'
+
+/** One registered platform entry, reduced to the durable facts duplicate detection needs. */
+export interface PlatformAccountDuplicateCandidate {
+ platformId: string
+ platformKind: string
+ /** Platform-owned user id, present once the adapter provisioned successfully. */
+ userId?: string
+ /**
+ * Platform entry that already owns this entry's virtual phone. Only set when
+ * provisioning failed on the deterministic phone invariant, which proves the
+ * entry describes an account another entry is already serving.
+ */
+ claimedBy?: string
+ /** Telegram clients signed in through the entry. */
+ clientAuthorizations: number
+}
+
+/**
+ * Group platform entries that describe the same platform account.
+ *
+ * Two entries are duplicates when either
+ * - their adapters resolved to the same platform user (`platformKind` plus `userId`), or
+ * - one of them could not be provisioned because another entry already owns its
+ * virtual phone, which is only possible for a platform identity that is already served.
+ *
+ * Entries that failed for any other reason (adapter offline, provider unsupported)
+ * carry no identity at all, so they are never reported as duplicates.
+ *
+ * The kept entry is the one clients are already signed in through, with the stable
+ * entry id order as the tie-breaker.
+ */
+export function findDuplicateAccounts(
+ candidates: readonly PlatformAccountDuplicateCandidate[],
+): PlatformAccountDuplicateGroup[] {
+ const known = new Set(candidates.map((candidate) => candidate.platformId))
+ const groups = new Map()
+ const claimed = new Set()
+ const groupFor = (keep: string, reason: PlatformAccountDuplicateGroup['reason']) => {
+ const existing = groups.get(keep)
+ if (existing) return existing
+ const group: PlatformAccountDuplicateGroup = { keep, remove: [], reason }
+ groups.set(keep, group)
+ return group
+ }
+ // Claimed entries come first so a failed duplicate is never also reported as an
+ // identity duplicate of the entry whose phone it failed to claim.
+ for (const candidate of candidates) {
+ if (!candidate.claimedBy || !known.has(candidate.claimedBy)) continue
+ groupFor(candidate.claimedBy, 'virtual-phone').remove.push(candidate.platformId)
+ claimed.add(candidate.platformId)
+ }
+ const byIdentity = new Map()
+ for (const candidate of candidates) {
+ if (claimed.has(candidate.platformId) || !candidate.userId) continue
+ const key = `${candidate.platformKind}\0${candidate.userId}`
+ const members = byIdentity.get(key)
+ if (members) members.push(candidate)
+ else byIdentity.set(key, [candidate])
+ }
+ for (const members of byIdentity.values()) {
+ if (members.length < 2) continue
+ const [keep, ...remove] = [...members].sort(compareDuplicateMembers)
+ const group = groupFor(keep!.platformId, 'identity')
+ for (const member of remove) {
+ if (!group.remove.includes(member.platformId)) group.remove.push(member.platformId)
+ }
+ }
+ return [...groups.values()]
+ .filter((group) => group.remove.length > 0)
+ .sort((left, right) => left.keep.localeCompare(right.keep))
+}
+
+function compareDuplicateMembers(
+ left: PlatformAccountDuplicateCandidate,
+ right: PlatformAccountDuplicateCandidate,
+): number {
+ return (
+ right.clientAuthorizations - left.clientAuthorizations ||
+ left.platformId.localeCompare(right.platformId)
+ )
+}
diff --git a/packages/bridge/src/account-removal-loader.test.ts b/packages/bridge/src/account-removal-loader.test.ts
new file mode 100644
index 0000000..c1da2ae
--- /dev/null
+++ b/packages/bridge/src/account-removal-loader.test.ts
@@ -0,0 +1,70 @@
+import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from 'cordis'
+import { Loader } from '@cordisjs/plugin-loader'
+import { afterEach, describe, expect, it } from 'vitest'
+import { isLoaderManagedEntry, platformEntry, removePlatformEntry } from './account-removal.js'
+
+const disposals: Array<() => Promise> = []
+
+afterEach(async () => {
+ for (const dispose of disposals.splice(0)) await dispose()
+})
+
+/**
+ * The real loader keys entries loaded from a config file with the path of the
+ * tree that loaded them, which app.yml goes through in production
+ * (`@cordisjs/plugin-cli-cordis` loads the file with `@cordisjs/plugin-include`).
+ * Deleting an account therefore has to be checked against the real tree, not a
+ * stand-in: removing the qualified key alone silently leaves the entry in place.
+ */
+async function fixture() {
+ const directory = await mkdtemp(join(tmpdir(), 'account-removal-loader-'))
+ const config = join(directory, 'app.yml')
+ await writeFile(join(directory, 'package.json'), JSON.stringify({
+ name: 'account-removal-loader-test', type: 'module', dependencies: {},
+ }), 'utf8')
+ const plugin = join(directory, 'plugin.mjs')
+ await writeFile(plugin, 'export function apply() {}', 'utf8')
+ const name = pathToFileURL(plugin).href
+ await writeFile(config, [
+ `- id: bridge01`, ` name: '${name}'`, '',
+ `- id: qqnt`, ` name: '${name}'`, '',
+ `- id: qqnt-2`, ` name: '${name}'`, '',
+ ].join('\n'), 'utf8')
+ const ctx = new Context()
+ const loader = ctx.plugin(Loader)
+ await loader
+ disposals.push(async () => { await Promise.resolve((loader as any).dispose?.()) })
+ await ctx.loader.create({
+ name: '@cordisjs/plugin-include',
+ config: { path: pathToFileURL(config).href, enableLogs: false },
+ })
+ await new Promise(resolve => setTimeout(resolve, 100))
+ return { ctx, config }
+}
+
+describe('platform entry removal against the real loader', () => {
+ it('finds and deletes an entry loaded from the configuration file', async () => {
+ const { ctx, config } = await fixture()
+ expect(platformEntry(ctx, 'qqnt-2')?.options.id).toBe('qqnt-2')
+ expect(platformEntry(ctx, 'qqnt-2')?.id).not.toBe('qqnt-2')
+ expect(isLoaderManagedEntry(ctx, 'qqnt')).toBe(true)
+
+ removePlatformEntry(ctx, 'qqnt-2')
+ const remaining = [...ctx.loader!.entries()].map(entry => entry.options.id)
+ expect(remaining).not.toContain('qqnt-2')
+ expect(remaining).toContain('qqnt')
+ // The owning tree writes the file back, so the duplicate cannot return on restart.
+ await new Promise(resolve => setTimeout(resolve, 50))
+ expect(await readFile(config, 'utf8')).not.toContain('qqnt-2')
+ expect(isLoaderManagedEntry(ctx, 'qqnt-2')).toBe(false)
+ })
+
+ it('refuses an entry the configuration file never declared', async () => {
+ const { ctx } = await fixture()
+ expect(() => removePlatformEntry(ctx, 'qqnt-3')).toThrow('不是由配置文件管理的')
+ })
+})
diff --git a/packages/bridge/src/account-removal.test.ts b/packages/bridge/src/account-removal.test.ts
new file mode 100644
index 0000000..b6db5ed
--- /dev/null
+++ b/packages/bridge/src/account-removal.test.ts
@@ -0,0 +1,155 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import Database from '@cordisjs/plugin-database'
+import SQLiteDriver from '@cordisjs/plugin-database-sqlite'
+import { defineModels } from './models.js'
+import {
+ countPlatformClientAuthorizations, isLoaderManagedEntry, platformEntry, removePlatformAccount,
+ removePlatformEntry,
+} from './account-removal.js'
+
+const disposals: Array<() => Promise> = []
+
+afterEach(async () => {
+ await Promise.all(disposals.splice(0).map(dispose => dispose()))
+})
+
+async function createDatabase() {
+ const ctx = new Context()
+ const fibers = [ctx.plugin(Database), ctx.plugin(SQLiteDriver, { path: ':memory:' })]
+ await Promise.all(fibers)
+ await new Promise(resolve => setTimeout(resolve, 25))
+ defineModels(ctx)
+ await ctx.database.prepared()
+ disposals.push(async () => {
+ for (const fiber of fibers.reverse()) await Promise.resolve((fiber as any).dispose?.())
+ })
+ return ctx.database
+}
+
+/** Seed one platform entry with a session, a virtual phone and one signed-in client. */
+async function seedAccount(
+ database: Awaited>,
+ platformId: string,
+ suffix: string,
+) {
+ await database.create('mtproto_platform_session', {
+ id: `session-${suffix}`, platformId, userId: `user-${suffix}`, credentials: {},
+ metadata: {}, active: true, createdAt: new Date(),
+ })
+ await database.create('mtproto_auth_session', {
+ id: `auth-${suffix}`, virtualPhone: `8880000000000${suffix}`, totpSecret: 'secret',
+ platformId, platformSessionId: `session-${suffix}`,
+ })
+ await database.create('mtproto_auth_binding', {
+ authKeyId: `key-${suffix}`, platformId, platformSessionId: `session-${suffix}`,
+ })
+ await database.create('mtproto_client_authorization', {
+ authKeyId: `key-${suffix}`, platformSessionId: `session-${suffix}`, apiId: 1,
+ deviceModel: 'device', platform: 'Android', systemVersion: '1', appName: 'Telegram',
+ appVersion: '1', dateCreated: 1, dateActive: 2, ip: '127.0.0.1', country: 'Local network',
+ region: '', encryptedRequestsDisabled: false, callRequestsDisabled: false, unconfirmed: false,
+ })
+ await database.create('mtproto_authorization_settings', {
+ platformSessionId: `session-${suffix}`, ttlDays: 180,
+ })
+}
+
+describe('platform account removal', () => {
+ it('removes one entry together with its login credentials and revokes its clients', async () => {
+ const database = await createDatabase()
+ await seedAccount(database, 'qqnt-2', '2')
+ await seedAccount(database, 'qqnt', '1')
+ const revoke = vi.fn(async () => {})
+
+ await expect(removePlatformAccount(database, 'qqnt-2', revoke)).resolves.toEqual({
+ platformSessions: 1,
+ authSessions: 1,
+ authBindings: 1,
+ clientAuthorizations: 1,
+ authorizationSettings: 1,
+ })
+ expect(revoke).toHaveBeenCalledWith(['key-2'])
+ expect(await database.get('mtproto_platform_session', { platformId: 'qqnt-2' })).toEqual([])
+ expect(await database.get('mtproto_auth_session', { platformId: 'qqnt-2' })).toEqual([])
+ expect(await database.get('mtproto_auth_binding', { platformId: 'qqnt-2' })).toEqual([])
+ expect(await database.get('mtproto_client_authorization', {})).toHaveLength(1)
+ expect(await database.get('mtproto_authorization_settings', {})).toHaveLength(1)
+ // The entry that keeps the account is untouched.
+ expect(await database.get('mtproto_auth_session', { platformId: 'qqnt' })).toHaveLength(1)
+ expect(await countPlatformClientAuthorizations(database, 'qqnt')).toBe(1)
+ expect(await countPlatformClientAuthorizations(database, 'qqnt-2')).toBe(0)
+ })
+
+ it('removes a legacy entry whose auth session points at a session row that is gone', async () => {
+ const database = await createDatabase()
+ await seedAccount(database, 'legacy', 'l')
+ await database.remove('mtproto_platform_session', { platformId: 'legacy' })
+
+ await expect(removePlatformAccount(database, 'legacy')).resolves.toMatchObject({
+ platformSessions: 0,
+ authSessions: 1,
+ clientAuthorizations: 1,
+ })
+ expect(await database.get('mtproto_auth_session', { platformId: 'legacy' })).toEqual([])
+ expect(await database.get('mtproto_client_authorization', {})).toEqual([])
+ })
+})
+
+describe('cordis entry removal', () => {
+ /** Minimal stand-in for the loader tree, keyed the way the real loader keys entries. */
+ const loader = (entries: Array<[string, string]>) => {
+ const removed: string[] = []
+ const tree = { remove: (id: string) => { removed.push(id) } }
+ return {
+ removed,
+ service: {
+ entries: () => entries.map(([id, configuredId]) => ({
+ id,
+ options: { id: configuredId },
+ parent: { tree },
+ })),
+ },
+ }
+ }
+ const context = (service?: unknown) => {
+ const ctx = new Context()
+ return {
+ ctx,
+ provide: () => ctx.provide('loader', service as never),
+ }
+ }
+
+ it('deletes the entry through the tree that owns it', () => {
+ const target = loader([['bridge01', 'bridge01'], ['qqnt-2', 'qqnt-2']])
+ const { ctx, provide } = context(target.service)
+ provide()
+ expect(isLoaderManagedEntry(ctx, 'qqnt-2')).toBe(true)
+ removePlatformEntry(ctx, 'qqnt-2')
+ // The owning tree removes entries by their configured id, not by loader key.
+ expect(target.removed).toEqual(['qqnt-2'])
+ })
+
+ it('finds an entry loaded from a config file under its qualified loader key', () => {
+ const target = loader([['7ffc5890:qqnt', 'qqnt'], ['7ffc5890:qqnt-2', 'qqnt-2']])
+ const { ctx, provide } = context(target.service)
+ provide()
+ expect(platformEntry(ctx, 'qqnt-2')?.id).toBe('7ffc5890:qqnt-2')
+ removePlatformEntry(ctx, 'qqnt')
+ expect(target.removed).toEqual(['qqnt'])
+ })
+
+ it('refuses to delete an entry the configuration file does not own', () => {
+ const { ctx, provide } = context(loader([['bridge01', 'bridge01']]).service)
+ provide()
+ expect(isLoaderManagedEntry(ctx, 'qqnt-2')).toBe(false)
+ expect(platformEntry(ctx, 'qqnt-2')).toBeUndefined()
+ expect(() => removePlatformEntry(ctx, 'qqnt-2')).toThrow(
+ '平台条目 qqnt-2 不是由配置文件管理的',
+ )
+ // A deployment without a loader keeps accounts undeletable rather than half deleted.
+ const bare = new Context()
+ expect(isLoaderManagedEntry(bare, 'qqnt-2')).toBe(false)
+ expect(() => removePlatformEntry(bare, 'qqnt-2')).toThrow('不是由配置文件管理的')
+ })
+})
diff --git a/packages/bridge/src/account-removal.ts b/packages/bridge/src/account-removal.ts
new file mode 100644
index 0000000..8dd81fb
--- /dev/null
+++ b/packages/bridge/src/account-removal.ts
@@ -0,0 +1,139 @@
+import type { Database } from '@cordisjs/plugin-database'
+import type { Context } from 'cordis'
+
+/** The Cordis configuration-tree surface the account page needs. */
+export interface PlatformEntryLoader {
+ entries(): Iterable
+}
+export interface PlatformEntry {
+ /** Loader key, qualified with the tree path for entries inside a config file. */
+ id: string
+ options: { id: string }
+ parent: { tree: { remove(id: string): void } }
+}
+
+export interface PlatformAccountRemovalCounts {
+ platformSessions: number
+ authSessions: number
+ authBindings: number
+ clientAuthorizations: number
+ authorizationSettings: number
+}
+
+/**
+ * Loader-owned configuration tree, when this deployment keeps plugin entries in
+ * a file. Without it an entry only exists in memory, so the bridge cannot make a
+ * deletion durable and refuses instead.
+ */
+export function platformEntryLoader(ctx: Context): PlatformEntryLoader | undefined {
+ const loader = ctx.get('loader') as PlatformEntryLoader | undefined
+ return loader && typeof loader.entries === 'function' ? loader : undefined
+}
+
+/**
+ * The loader entry that provisions this platform account, if a config file owns it.
+ *
+ * A platform entry loaded from a config file reports its configured id, while the
+ * loader keys the same entry with the path of the tree that loaded it, so both
+ * spellings are accepted.
+ */
+export function platformEntry(ctx: Context, platformId: string): PlatformEntry | undefined {
+ const loader = platformEntryLoader(ctx)
+ if (!loader) return undefined
+ const entries = [...loader.entries()]
+ return entries.find(entry => entry.id === platformId)
+ ?? entries.find(entry => entry.options?.id === platformId)
+}
+
+/** Whether the Cordis loader owns this entry id and can delete it. */
+export function isLoaderManagedEntry(ctx: Context, entryId: string): boolean {
+ return platformEntry(ctx, entryId) !== undefined
+}
+
+/**
+ * Delete the plugin entry that provisions a platform account. The entry is
+ * removed through the tree that owns it, which is also the tree that writes the
+ * configuration file back, so the account cannot return on the next start.
+ */
+export function removePlatformEntry(ctx: Context, entryId: string): void {
+ const entry = platformEntry(ctx, entryId)
+ if (!entry) {
+ throw new Error(`平台条目 ${entryId} 不是由配置文件管理的,请到插件页面手动停用它。`)
+ }
+ entry.parent.tree.remove(entry.options.id)
+}
+
+/**
+ * Remove the login surface of one platform entry: the platform session, its
+ * virtual phone, TOTP secret and two-step password, the bindings of every
+ * Telegram client signed in through it, and their authorizations.
+ *
+ * Message and conversation history is deliberately left alone: it is scoped by
+ * platform session, so it is unreachable once the session is gone, while the
+ * identity rows stay reusable if the same entry id is configured again.
+ */
+export async function removePlatformAccount(
+ database: Database,
+ platformId: string,
+ revokeAuthKeys: (authKeyIds: string[]) => Promise = async () => {},
+): Promise {
+ await database.prepared()
+ const [sessions, authSessions, bindings] = await Promise.all([
+ database.get('mtproto_platform_session', { platformId }),
+ database.get('mtproto_auth_session', { platformId }),
+ database.get('mtproto_auth_binding', { platformId }),
+ ])
+ const platformSessionIds = [...new Set([
+ ...sessions.map(session => session.id),
+ ...authSessions.map(auth => auth.platformSessionId),
+ ])]
+ let clientAuthorizations = 0
+ let authorizationSettings = 0
+ for (const platformSessionId of platformSessionIds) {
+ const [clients, settings] = await Promise.all([
+ database.get('mtproto_client_authorization', { platformSessionId }),
+ database.get('mtproto_authorization_settings', { platformSessionId }),
+ ])
+ clientAuthorizations += clients.length
+ authorizationSettings += settings.length
+ await Promise.all([
+ database.remove('mtproto_client_authorization', { platformSessionId }),
+ database.remove('mtproto_authorization_settings', { platformSessionId }),
+ ])
+ }
+ await Promise.all([
+ database.remove('mtproto_auth_binding', { platformId }),
+ database.remove('mtproto_auth_session', { platformId }),
+ database.remove('mtproto_platform_session', { platformId }),
+ ])
+ // Revoke after the rows are gone, so a client cannot re-authorize in between.
+ await revokeAuthKeys(bindings.map(binding => binding.authKeyId))
+ return {
+ platformSessions: sessions.length,
+ authSessions: authSessions.length,
+ authBindings: bindings.length,
+ clientAuthorizations,
+ authorizationSettings,
+ }
+}
+
+/** Telegram clients signed in through one platform entry. */
+export async function countPlatformClientAuthorizations(
+ database: Database,
+ platformId: string,
+): Promise {
+ await database.prepared()
+ const [sessions, authSessions] = await Promise.all([
+ database.get('mtproto_platform_session', { platformId }),
+ database.get('mtproto_auth_session', { platformId }),
+ ])
+ const platformSessionIds = [...new Set([
+ ...sessions.map(session => session.id),
+ ...authSessions.map(auth => auth.platformSessionId),
+ ])]
+ let count = 0
+ for (const platformSessionId of platformSessionIds) {
+ count += (await database.get('mtproto_client_authorization', { platformSessionId })).length
+ }
+ return count
+}
diff --git a/packages/bridge/src/dashboard-types.ts b/packages/bridge/src/dashboard-types.ts
index 9bedef8..4beb957 100644
--- a/packages/bridge/src/dashboard-types.ts
+++ b/packages/bridge/src/dashboard-types.ts
@@ -26,6 +26,44 @@ export interface CrossGramServerConfigDc {
port: number
}
+/** One `host:port` a copied server configuration can point at. */
+export interface PlatformAccountServerEndpoint {
+ host: string
+ port: number
+ /** The endpoint the bridge advertises first; also the default selection for a copy. */
+ primary: boolean
+}
+
+/** One platform entry that describes the same platform account as another entry. */
+export interface PlatformAccountDuplicateGroup {
+ /** Entry that keeps the account. */
+ keep: string
+ /** Entries that duplicate `keep`. */
+ remove: string[]
+ /**
+ * `identity`: both entries resolve to the same platform user.
+ * `virtual-phone`: the entry lost its deterministic virtual phone to `keep`.
+ */
+ reason: 'identity' | 'virtual-phone'
+}
+
+/** What deleting one platform entry removes, resolved before the confirmation. */
+export interface PlatformAccountRemovalTarget {
+ platformId: string
+ platformKind: string
+ displayName?: string
+ /** Telegram clients signed in through the entry; deleting it signs them out. */
+ clientAuthorizations: number
+ /** Whether the Cordis loader owns the entry, so the bridge can delete it. */
+ managed: boolean
+}
+
+export interface PlatformAccountRemovalPreview {
+ targets: PlatformAccountRemovalTarget[]
+ /** Sum of `targets[].clientAuthorizations`. */
+ clientAuthorizations: number
+}
+
export interface CrossGramServerConfig {
name: 'CrossGram'
enable_special_config: false
@@ -38,11 +76,21 @@ export interface CrossGramServerConfig {
export interface PlatformAccountDashboardData {
accounts: PlatformAccountView[]
serverConfig: CrossGramServerConfig
+ /** Endpoints `serverConfig` can be copied for; the primary entry comes first. */
+ serverEndpoints: PlatformAccountServerEndpoint[]
loginTokenApprovalUrl: string
updatedAt: number
refresh(): Promise
/** Set, replace, or clear (null/empty) the two-step verification password. */
setLoginPassword(platformId: string, password: string | null): Promise
+ /** Platform entries that describe the same platform account as another entry. */
+ findDuplicateAccounts(): Promise
+ /** Resolve what deleting these entries would remove, before the confirmation. */
+ describeAccountRemoval(
+ platformIds: string[],
+ ): Promise
+ /** Delete platform entries together with every login credential they own. */
+ deleteAccounts(platformIds: string[]): Promise
}
export interface StickerDashboardAccount {
diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts
index 9953de4..8b0a6dd 100644
--- a/packages/bridge/src/index.ts
+++ b/packages/bridge/src/index.ts
@@ -29,7 +29,8 @@ import { StickerRpc } from './sticker-rpc.js'
import { ReactionRpc } from './reaction-rpc.js'
import { TelegramResourceService } from './resource-provider.js'
import {
- migrateLegacyVirtualPhones, PlatformAccountProvisioner, type ProvisionedPlatformAccount,
+ migrateLegacyVirtualPhones, PlatformAccountProvisioner, VirtualPhoneClaimedError,
+ type ProvisionedPlatformAccount,
} from './platform-account.js'
import { verifyLoginCode } from './login-code.js'
import {
@@ -38,9 +39,14 @@ import {
import { DraftStore } from './draft-store.js'
import { NotificationSettingsStore } from './notification-settings.js'
import {
- makeCrossGramServerConfig, makePlatformAccountView, makeUnavailableAccountView,
+ makeCrossGramServerConfig, makePlatformAccountView, makeServerEndpoints, makeUnavailableAccountView,
type PlatformAccountDashboardData,
} from './account-dashboard.js'
+import type { PlatformAccountRemovalTarget } from './dashboard-types.js'
+import { findDuplicateAccounts, type PlatformAccountDuplicateCandidate } from './account-duplicates.js'
+import {
+ countPlatformClientAuthorizations, isLoaderManagedEntry, removePlatformAccount, removePlatformEntry,
+} from './account-removal.js'
import { AuthTransferStore } from './auth-transfer.js'
import {
LoginTokenStore, LoginTokenStoreFullError, LoginTokenSourceLimitError, parseTelegramLoginToken,
@@ -251,6 +257,18 @@ export function apply(ctx: Context, config: BridgeConfig = {}): void {
(authKeyId, originConnection) => ctx.mtproto.beginAuthKeyRevocation(authKeyId, originConnection),
authKeyId => ctx.mtproto.finishAuthKeyRevocation(authKeyId),
)
+ // Deleting a platform account signs out every client that logged in through
+ // it. Revocation is best effort after the rows are gone, so one stuck key can
+ // never keep a deleted account around.
+ const revokeBoundClients = async (authKeyIds: string[]): Promise => {
+ for (const authKeyId of authKeyIds) {
+ try {
+ await ctx.mtproto.revokeAuthKey(new Uint8Array(Buffer.from(authKeyId, 'hex')))
+ } catch (error) {
+ bridgeLogger.warn('failed to revoke authorization of a deleted account (%s): %s', authKeyId, String(error))
+ }
+ }
+ }
const store = new MessageStore(ctx.database, undefined, ctx.updateStore, historyTrace, messageProjection)
const drafts = new DraftStore(ctx.database)
const notificationSettings = new NotificationSettingsStore(
@@ -545,6 +563,9 @@ export function apply(ctx: Context, config: BridgeConfig = {}): void {
serverConfig: makeCrossGramServerConfig(
config.serverHost ?? '127.0.0.1', config.serverPort ?? 4430, ctx.mtproto.rsaKey.publicKeyPem,
),
+ serverEndpoints: makeServerEndpoints(
+ config.serverHost ?? '127.0.0.1', config.serverPort ?? 4430, config.altEndpoints,
+ ),
loginTokenApprovalUrl: `${apiPrefix}/login-tokens`,
updatedAt: Date.now(),
stickerAccounts: [],
@@ -566,6 +587,72 @@ export function apply(ctx: Context, config: BridgeConfig = {}): void {
async refreshBots() {
await publishBots()
},
+ async findDuplicateAccounts() {
+ await ctx.database.prepared()
+ const candidates = await Promise.all([...registry.ids].sort().map(
+ async (platformId): Promise => {
+ const platform = registry.get(platformId)
+ const error = accountErrors.get(platformId)
+ return {
+ platformId,
+ platformKind: platform?.platformKind ?? platformId,
+ userId: provisionedAccounts.get(platformId)?.profile.id,
+ claimedBy: error instanceof VirtualPhoneClaimedError ? error.ownerPlatformId : undefined,
+ clientAuthorizations: await countPlatformClientAuthorizations(ctx.database, platformId),
+ }
+ },
+ ))
+ return findDuplicateAccounts(candidates)
+ },
+ async describeAccountRemoval(platformIds) {
+ await ctx.database.prepared()
+ const requested = normalizePlatformIds(platformIds)
+ const targets: PlatformAccountRemovalTarget[] = []
+ for (const platformId of requested) {
+ const platform = registry.get(platformId)
+ const account = provisionedAccounts.get(platformId)
+ targets.push({
+ platformId,
+ platformKind: platform?.platformKind ?? platformId,
+ displayName: account
+ ? [account.profile.firstName, account.profile.lastName].filter(Boolean).join(' ')
+ || account.profile.username
+ : undefined,
+ clientAuthorizations: await countPlatformClientAuthorizations(ctx.database, platformId),
+ managed: isLoaderManagedEntry(ctx, platformId),
+ })
+ }
+ return {
+ targets,
+ clientAuthorizations: targets.reduce((total, target) => total + target.clientAuthorizations, 0),
+ }
+ },
+ async deleteAccounts(platformIds) {
+ await ctx.database.prepared()
+ const requested = normalizePlatformIds(platformIds)
+ if (!requested.length) throw new Error('没有选择要删除的账号。')
+ // Validate every entry before removing anything, so an unmanaged entry can
+ // never leave the configuration half deleted.
+ for (const platformId of requested) {
+ if (!registry.get(platformId)) throw new Error(`平台条目不存在:${platformId},请刷新后重试。`)
+ if (!isLoaderManagedEntry(ctx, platformId)) {
+ throw new Error(`平台条目 ${platformId} 不是由配置文件管理的,请到插件页面手动停用它。`)
+ }
+ }
+ for (const platformId of requested) {
+ const platform = registry.require(platformId)
+ platforms.deactivateSession(platformId, platform)
+ await subscriptions.stopPlatform(platformId)
+ await removePlatformAccount(ctx.database, platformId, revokeBoundClients)
+ provisionedAccounts.delete(platformId)
+ accountErrors.delete(platformId)
+ // Removing the entry unregisters the adapter, so provisioning cannot
+ // recreate the account, and the plugin list stays in sync with app.yml.
+ removePlatformEntry(ctx, platformId)
+ }
+ publishAccounts()
+ await publishStickerPacks()
+ },
async setStickerPackAssigned(platformSessionId, providerId, packId, assigned) {
const pack = publishedStickerPacks.find((item) =>
item.providerId === providerId && item.packId === packId)
@@ -1458,6 +1545,13 @@ function normPhone(p: string): string {
return p.replace(/\D/g, '')
}
+/** Deduplicate requested platform entry ids; the browser may repeat them across sections. */
+function normalizePlatformIds(platformIds: readonly string[]): string[] {
+ return [...new Set(platformIds.filter(
+ (platformId): platformId is string => typeof platformId === 'string' && platformId.length > 0,
+ ))]
+}
+
/** Completes a new authorization only after its binding can receive transient replays. */
export async function finalizeAuthorizedSession(
rpc: ServerRpcContext,
diff --git a/packages/bridge/src/platform-account.ts b/packages/bridge/src/platform-account.ts
index e5065aa..6e2e662 100644
--- a/packages/bridge/src/platform-account.ts
+++ b/packages/bridge/src/platform-account.ts
@@ -157,9 +157,24 @@ async function allocateVirtualPhone(database: Database): Promise {
throw new Error('failed to allocate a unique virtual phone')
}
+/**
+ * Raised when a platform entry cannot own the virtual phone its identity maps
+ * to because another entry already serves it. The account page uses this to
+ * name the entry that owns the account and to offer deleting the duplicate.
+ */
+export class VirtualPhoneClaimedError extends Error {
+ constructor(
+ readonly virtualPhone: string,
+ readonly ownerPlatformId: string,
+ ) {
+ super(`virtual phone +${virtualPhone} is already assigned to platform entry "${ownerPlatformId}"`)
+ this.name = 'VirtualPhoneClaimedError'
+ }
+}
+
async function ensureVirtualPhoneAvailable(database: Database, virtualPhone: string, authId?: string): Promise {
const owner = (await database.get('mtproto_auth_session', { virtualPhone })).find(auth => auth.id !== authId)
- if (owner) throw new Error(`QQ virtual phone ${virtualPhone} is already assigned to another auth session`)
+ if (owner) throw new VirtualPhoneClaimedError(virtualPhone, owner.platformId)
}
function qqVirtualPhone(user: IMUser): string {
diff --git a/packages/cordis-webui-solidjs/client/pages/crossgram-components.test.tsx b/packages/cordis-webui-solidjs/client/pages/crossgram-components.test.tsx
index 7b866da..ac11762 100644
--- a/packages/cordis-webui-solidjs/client/pages/crossgram-components.test.tsx
+++ b/packages/cordis-webui-solidjs/client/pages/crossgram-components.test.tsx
@@ -31,7 +31,15 @@ describe('bounded Crossgram components', () => {
validUntil: 3000,
})
const root = mount(() => (
- {}} />
+ {}}
+ onDelete={() => {}}
+ setPassword={async () => {}}
+ />
))
const card = root.querySelector('article')!,
copy = root.querySelector(
@@ -47,6 +55,42 @@ describe('bounded Crossgram components', () => {
expect(copy.disabled).toBe(true)
expect(copy.textContent).toBe('------')
})
+ it('selects and marks a duplicate card without disturbing the account card itself', () => {
+ const selected: boolean[] = []
+ const [marker, setMarker] = createSignal(undefined)
+ const root = mount(() => (
+ selected.push(value)}
+ onDelete={() => {}}
+ setPassword={async () => {}}
+ />
+ ))
+ const checkbox = root.querySelector(
+ '[aria-label="Select qqnt-2"]',
+ )!
+ checkbox.checked = true
+ checkbox.dispatchEvent(new Event('change', { bubbles: true }))
+ expect(selected).toEqual([true])
+ expect(root.querySelector('[data-duplicate-of]')).toBeNull()
+ setMarker('duplicate')
+ expect(root.querySelector('[data-duplicate-of]')?.textContent).toContain(
+ 'Duplicate of qqnt',
+ )
+ expect(
+ root.querySelector('[aria-label="Delete qqnt-2"]')
+ ?.textContent,
+ ).toBe('Delete account')
+ })
it('renders only one page of large statistics tables and reuses table rows across live snapshots', () => {
const [rows, setRows] = createSignal(
Array.from({ length: 5000 }, (_, index) => ({
diff --git a/packages/cordis-webui-solidjs/src/bridge.browser.e2e.test.ts b/packages/cordis-webui-solidjs/src/bridge.browser.e2e.test.ts
index f107a81..9301cf3 100644
--- a/packages/cordis-webui-solidjs/src/bridge.browser.e2e.test.ts
+++ b/packages/cordis-webui-solidjs/src/bridge.browser.e2e.test.ts
@@ -11,6 +11,7 @@ import {
LoginTokenStore,
parseTelegramLoginToken,
} from '../../bridge/src/login-token.js'
+import { findDuplicateAccounts } from '../../bridge/src/account-duplicates.js'
import SolidWebUI from './index.js'
describe('Crossgram accounts, stickers and bots in the Solid shell', () => {
it('keeps identity cards stable, decodes QR in a worker, requires explicit account approval and manages sticker/bot pages on a phone', async () => {
@@ -32,7 +33,9 @@ describe('Crossgram accounts, stickers and bots in the Solid shell', () => {
const tokenUrl =
'tg://login?token=' + Buffer.from(issued).toString('base64url'),
approvals: unknown[] = [],
- assignments: unknown[][] = []
+ assignments: unknown[][] = [],
+ described: string[][] = [],
+ removed: string[][] = []
ctx.server.post('/bridge/login-tokens/:platform/approve', async (req) => {
const body = await req.json(),
token = parseTelegramLoginToken(body.token)
@@ -76,15 +79,33 @@ describe('Crossgram accounts, stickers and bots in the Solid shell', () => {
status: 'error',
error: 'Platform disconnected',
},
+ {
+ // A second QQ entry that could not claim the virtual phone of `qq-main`
+ // carries no profile, exactly like the backend error projection.
+ platformId: 'qq-duplicate',
+ platformKind: 'QQ',
+ status: 'error',
+ userId: '12345',
+ virtualPhone: '+888123456789',
+ error: 'virtual phone is already assigned to platform entry',
+ },
],
serverConfig: {
name: 'CrossGram',
enable_special_config: false,
host: 'example.test',
port: 4430,
- rsa_key: 'PUBLIC_TEST_KEY',
- dcs: [],
+ rsa_key:
+ '-----BEGIN RSA PUBLIC KEY-----\nPUBLIC_TEST_KEY\n-----END RSA PUBLIC KEY-----',
+ dcs: [
+ { id: 1, ip: 'example.test', port: 4430 },
+ { id: 2, ip: 'example.test', port: 4430 },
+ ],
},
+ serverEndpoints: [
+ { host: 'example.test', port: 4430, primary: true },
+ { host: 'backup.test', port: 8443, primary: false },
+ ],
loginTokenApprovalUrl: '/bridge/login-tokens',
updatedAt: Date.now(),
stickerAccounts: [
@@ -139,6 +160,52 @@ describe('Crossgram accounts, stickers and bots in the Solid shell', () => {
async refreshBots() {},
async refreshStickerPacks() {},
async setLoginPassword() {},
+ async findDuplicateAccounts() {
+ return findDuplicateAccounts(
+ data.accounts.map((account) => ({
+ platformId: account.platformId,
+ platformKind: account.platformKind,
+ userId: account.userId,
+ clientAuthorizations:
+ account.platformId === 'qq-main'
+ ? 2
+ : account.platformId === 'matrix-alt'
+ ? 1
+ : 0,
+ })),
+ )
+ },
+ async describeAccountRemoval(platformIds) {
+ described.push(platformIds)
+ return {
+ targets: platformIds.map((platformId) => ({
+ platformId,
+ platformKind:
+ data.accounts.find((account) => account.platformId === platformId)
+ ?.platformKind ?? 'unknown',
+ displayName: data.accounts.find(
+ (account) => account.platformId === platformId,
+ )?.displayName,
+ clientAuthorizations:
+ platformId === 'qq-main' ? 2 : platformId === 'matrix-alt' ? 1 : 0,
+ managed: platformId !== 'offline',
+ })),
+ clientAuthorizations: platformIds.reduce(
+ (total, platformId) =>
+ total +
+ (platformId === 'qq-main' ? 2 : platformId === 'matrix-alt' ? 1 : 0),
+ 0,
+ ),
+ }
+ },
+ async deleteAccounts(platformIds) {
+ removed.push(platformIds)
+ entry.mutate((value) => {
+ value.accounts = value.accounts.filter(
+ (account) => !platformIds.includes(account.platformId),
+ )
+ })
+ },
async setStickerPackAssigned(account, provider, pack, assigned) {
assignments.push([account, provider, pack, assigned])
entry.mutate((value) => {
@@ -237,9 +304,64 @@ describe('Crossgram accounts, stickers and bots in the Solid shell', () => {
await page
.getByRole('button', { name: 'Copy server configuration', exact: true })
.click()
- expect(
- JSON.parse(await page.evaluate(() => navigator.clipboard.readText())),
- ).toMatchObject({ host: 'example.test', rsa_key: 'PUBLIC_TEST_KEY' })
+ const copiedConfiguration = await page.evaluate(() =>
+ navigator.clipboard.readText(),
+ )
+ expect(JSON.parse(copiedConfiguration)).toEqual(data.serverConfig)
+ expect(copiedConfiguration).toContain('PUBLIC_TEST_KEY')
+ // Phones open the console over plain http, where navigator.clipboard is missing and
+ // copyText falls back to a hidden textarea; that path must produce the same document.
+ await page.evaluate(() =>
+ Object.defineProperty(navigator.clipboard, 'writeText', {
+ value: undefined,
+ configurable: true,
+ }),
+ )
+ await page
+ .getByRole('button', { name: 'Configuration copied', exact: true })
+ .click()
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
+ copiedConfiguration,
+ )
+ // The clipboard and the readable document on the page are the same configuration.
+ const displayedConfiguration = await page
+ .getByLabel('Server configuration')
+ .textContent()
+ expect(JSON.parse(displayedConfiguration!)).toEqual(
+ JSON.parse(copiedConfiguration),
+ )
+ // Copying for another endpoint only rewrites host/port, so a reader who needs
+ // the backup address does not have to edit the pasted document by hand. The
+ // mobile fallback path above is still active, so this covers it as well.
+ const endpointPicker = page.getByLabel('Copy endpoint')
+ expect(await endpointPicker.inputValue()).toBe('example.test:4430')
+ await page
+ .getByRole('button', { name: 'Configuration copied', exact: true })
+ .click()
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
+ copiedConfiguration,
+ )
+ await endpointPicker.selectOption('backup.test:8443')
+ expect(await page.getByLabel('Server configuration').textContent()).toContain(
+ '"host": "backup.test"',
+ )
+ await page
+ .getByRole('button', { name: 'Configuration copied', exact: true })
+ .click()
+ const alternateConfiguration = await page.evaluate(() =>
+ navigator.clipboard.readText(),
+ )
+ expect(JSON.parse(alternateConfiguration)).toEqual({
+ ...data.serverConfig,
+ host: 'backup.test',
+ port: 8443,
+ dcs: data.serverConfig.dcs.map((dc) => ({
+ ...dc,
+ ip: 'backup.test',
+ port: 8443,
+ })),
+ })
+ await endpointPicker.selectOption('example.test:4430')
await page
.getByRole('button', { name: 'Approve QR login', exact: true })
.click()
@@ -340,6 +462,95 @@ describe('Crossgram accounts, stickers and bots in the Solid shell', () => {
await page
.getByRole('heading', { name: 'Primary account', exact: true })
.waitFor()
+
+ // Duplicate entries are reported by the backend with the entry that owns the
+ // account, so the reader can see what would go before selecting anything.
+ const duplicates = page.locator('[data-duplicates]')
+ await duplicates.getByText('1 duplicate account', { exact: true }).waitFor()
+ expect(await duplicates.textContent()).toContain(
+ 'qq-duplicate duplicates qq-main',
+ )
+ const duplicateCard = page.locator('[data-platform="qq-duplicate"]')
+ expect(
+ await duplicateCard.locator('[data-duplicate-of]').textContent(),
+ ).toContain('Duplicate of qq-main')
+ if (process.env.WEBUI_SCREENSHOTS) {
+ await mkdir(process.env.WEBUI_SCREENSHOTS, { recursive: true })
+ await page.screenshot({
+ path: join(
+ process.env.WEBUI_SCREENSHOTS,
+ 'accounts-duplicates-mobile.png',
+ ),
+ fullPage: true,
+ animations: 'disabled',
+ })
+ }
+
+ // An entry the configuration file does not own cannot be deleted, and the
+ // dialog says so instead of dropping the entry from the runtime only.
+ await page.getByRole('button', { name: 'Delete offline', exact: true }).click()
+ const unmanagedDialog = page.getByRole('dialog', {
+ name: 'Delete platform accounts',
+ })
+ await unmanagedDialog.getByText('offline is not managed by').waitFor()
+ expect(
+ await unmanagedDialog
+ .getByRole('button', { name: 'Delete accounts', exact: true })
+ .isDisabled(),
+ ).toBe(true)
+ await unmanagedDialog
+ .getByRole('button', { name: 'Cancel', exact: true })
+ .click()
+
+ // Deleting an account clients are signed in through is confirmed with the
+ // exact number of sessions that lose access.
+ await page
+ .getByRole('button', { name: 'Delete qq-main', exact: true })
+ .click()
+ const inUseDialog = page.getByRole('dialog', {
+ name: 'Delete platform accounts',
+ })
+ await inUseDialog
+ .getByText(/2 Telegram clients signed in through the selected entries will be signed out\./)
+ .waitFor()
+ await inUseDialog
+ .getByRole('button', { name: 'Cancel', exact: true })
+ .click()
+
+ await page
+ .getByRole('button', { name: 'Select duplicates', exact: true })
+ .click()
+ await page.getByText('1 selected', { exact: true }).waitFor()
+ expect(
+ await page
+ .getByRole('checkbox', { name: 'Select qq-duplicate', exact: true })
+ .isChecked(),
+ ).toBe(true)
+ await page
+ .getByRole('button', { name: 'Delete selected', exact: true })
+ .click()
+ const deleteDialog = page.getByRole('dialog', {
+ name: 'Delete platform accounts',
+ })
+ await deleteDialog.getByText('qq-duplicate', { exact: false }).first().waitFor()
+ if (process.env.WEBUI_SCREENSHOTS) {
+ await page.screenshot({
+ path: join(
+ process.env.WEBUI_SCREENSHOTS,
+ 'accounts-delete-confirm-mobile.png',
+ ),
+ animations: 'disabled',
+ })
+ }
+ await deleteDialog
+ .getByRole('button', { name: 'Delete accounts', exact: true })
+ .click()
+ await page.locator('[data-platform="qq-duplicate"]').waitFor({ state: 'detached' })
+ await duplicates.waitFor({ state: 'detached' })
+ expect(removed).toEqual([['qq-duplicate']])
+ expect(described).toContainEqual(['qq-duplicate'])
+ expect(await page.locator('.identity-card').count()).toBe(3)
+
if (process.env.WEBUI_SCREENSHOTS) {
await mkdir(process.env.WEBUI_SCREENSHOTS, { recursive: true })
await page.screenshot({
@@ -359,6 +570,32 @@ describe('Crossgram accounts, stickers and bots in the Solid shell', () => {
fullPage: true,
animations: 'disabled',
})
+ // A native