diff --git a/packages/functional-tests/pages/layout.ts b/packages/functional-tests/pages/layout.ts index 6a2df4a5df2..fc7698435e9 100644 --- a/packages/functional-tests/pages/layout.ts +++ b/packages/functional-tests/pages/layout.ts @@ -73,6 +73,30 @@ export abstract class BaseLayout { }); } + /** + * Installs a page-side listener for the next `WebChannelMessageToChrome` + * event matching `command` and returns a promise that resolves with the + * event's `data` payload. Call BEFORE the action that triggers the message + * (e.g. before clicking a sign-in button) to avoid the polling/timeout race + * that `checkWebChannelMessage` is subject to on slow runners. + */ + async waitForWebChannelMessage( + command: FirefoxCommand + ): Promise> { + return this.page.evaluate( + (expected) => + new Promise>((resolve) => { + window.addEventListener('WebChannelMessageToChrome', (e: Event) => { + const detail = JSON.parse((e as CustomEvent).detail); + if (detail.message.command === expected) { + resolve(detail.message.data || {}); + } + }); + }), + command + ); + } + async checkWebChannelMessage(command: FirefoxCommand) { // Retry across navigations — a client-side redirect after page.goto // can destroy the execution context mid-evaluate. diff --git a/packages/functional-tests/tests/admin/adminPanel.spec.ts b/packages/functional-tests/tests/admin/adminPanel.spec.ts index f835946e80b..604414d4169 100644 --- a/packages/functional-tests/tests/admin/adminPanel.spec.ts +++ b/packages/functional-tests/tests/admin/adminPanel.spec.ts @@ -5,7 +5,8 @@ import { expect, test } from '../../lib/fixtures/standard'; const ADMIN_PANEL_URL = process.env.ADMIN_PANEL_URL ?? 'http://localhost:8091'; -const ADMIN_SERVER_URL = process.env.ADMIN_SERVER_URL ?? 'http://localhost:8095'; +const ADMIN_SERVER_URL = + process.env.ADMIN_SERVER_URL ?? 'http://localhost:8095'; // Admin panel tests only run locally (stage/prod require SSO) test.skip(({ target }) => target.name !== 'local'); @@ -67,4 +68,48 @@ test.describe('Admin Panel', () => { timeout: 10000, }); }); + + test('account by-uid response includes accountAuthorizations field', async ({ + target, + testAccountTracker, + }) => { + const details = testAccountTracker.generateAccountDetails(); + const credentials = await target.createAccount( + details.email, + details.password + ); + + const res = await fetch( + `${ADMIN_SERVER_URL}/api/account/by-uid?uid=${encodeURIComponent(credentials.uid)}`, + { + headers: { + 'oidc-claim-id-token-email': 'test-admin@mozilla.com', + 'remote-groups': 'vpn_fxa_admin_panel_prod', + }, + } + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.accountAuthorizations)).toBe(true); + // A brand-new account has no browser-service authorizations on record. + expect(data.accountAuthorizations).toEqual([]); + }); + + test('admin panel renders the authorized browser services section', async ({ + target, + page, + testAccountTracker, + }) => { + const credentials = testAccountTracker.generateAccountDetails(); + await target.createAccount(credentials.email, credentials.password); + + await page.goto(`${ADMIN_PANEL_URL}/account-search`); + await page.getByTestId('email-input').fill(credentials.email); + await page.getByTestId('search-button').click(); + + await expect( + page.getByRole('heading', { name: /authorized browser services/i }) + ).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('account-authorizations-none')).toBeVisible(); + }); }); diff --git a/packages/functional-tests/tests/misc/vpnIntegration.spec.ts b/packages/functional-tests/tests/misc/vpnIntegration.spec.ts index b33ff51ddc0..7a591398a87 100644 --- a/packages/functional-tests/tests/misc/vpnIntegration.spec.ts +++ b/packages/functional-tests/tests/misc/vpnIntegration.spec.ts @@ -33,17 +33,21 @@ test.describe('vpn integration', () => { await expect(signin.cachedSigninHeading).toBeVisible(); await expect(page.getByText(email)).toBeVisible(); + // Install listeners before the click so the dispatched events can't be + // missed by a slow polling cycle. + const oauthLoginPromise = signin.waitForWebChannelMessage( + FirefoxCommand.OAuthLogin + ); + const loginPromise = signin.waitForWebChannelMessage(FirefoxCommand.Login); + await signin.signInButton.click(); - // Verify fxaOAuthLogin was sent with VPN scopes - await signin.checkWebChannelMessageScopes( - FirefoxCommand.OAuthLogin, - 'https://identity.mozilla.com/apps/vpn' + const oauthLogin = await oauthLoginPromise; + expect(oauthLogin.scopes).toEqual( + expect.stringContaining('https://identity.mozilla.com/apps/vpn') ); - // Verify services data includes vpn - await signin.checkWebChannelMessageServices(FirefoxCommand.Login, { - vpn: {}, - }); + const login = await loginPromise; + expect(login.services).toEqual({ vpn: {} }); }); }); diff --git a/packages/fxa-admin-panel/src/components/PageAccountSearch/Account/index.test.tsx b/packages/fxa-admin-panel/src/components/PageAccountSearch/Account/index.test.tsx index deb70658b3d..12696897e1c 100644 --- a/packages/fxa-admin-panel/src/components/PageAccountSearch/Account/index.test.tsx +++ b/packages/fxa-admin-panel/src/components/PageAccountSearch/Account/index.test.tsx @@ -100,6 +100,7 @@ let accountResponse: AccountProps = { linkedAccounts: [], accountEvents: [], passkeys: [], + accountAuthorizations: [], }; it('renders without imploding', () => { @@ -319,6 +320,37 @@ it('displays the locale', async () => { expect(getByTestId('edit-account-locale')).toBeInTheDocument(); }); +it('shows "no authorizations" message when authorizations list is empty', () => { + const { getByTestId } = render(); + expect(getByTestId('account-authorizations-none')).toBeInTheDocument(); +}); + +it('displays authorized browser services', () => { + const withAuthorizations = { + ...accountResponse, + accountAuthorizations: [ + { + service: 'sync', + scope: 'https://identity.mozilla.com/apps/oldsync', + authorizedAt: 1589467100316, + }, + { + service: 'relay', + scope: 'https://identity.mozilla.com/apps/relay', + authorizedAt: 1589467200000, + }, + ], + }; + const { getAllByTestId, getByRole } = render( + + ); + + expect( + getByRole('heading', { name: /authorized browser services/i }) + ).toBeInTheDocument(); + expect(getAllByTestId('account-authorization-service')).toHaveLength(2); +}); + it('displays key-stretch-version', async () => { const lockedAccount = { ...accountResponse, diff --git a/packages/fxa-admin-panel/src/components/PageAccountSearch/Account/index.tsx b/packages/fxa-admin-panel/src/components/PageAccountSearch/Account/index.tsx index a99040f9e0f..942f6d8dbfe 100644 --- a/packages/fxa-admin-panel/src/components/PageAccountSearch/Account/index.tsx +++ b/packages/fxa-admin-panel/src/components/PageAccountSearch/Account/index.tsx @@ -16,6 +16,7 @@ import { AdminPanelFeature } from '@fxa/shared/guards'; import Guard from '../../Guard'; import Subscription from '../Subscription'; import { ConnectedServices } from '../ConnectedServices'; +import { AccountAuthorizations } from '../AccountAuthorizations'; import { TableRowYHeader, TableYHeaders } from '../../TableYHeaders'; import { TableRowXHeader, TableXHeaders } from '../../TableXHeaders'; import EmailBounces from '../EmailBounces'; @@ -94,6 +95,7 @@ export const Account = ({ backupCodes, recoveryPhone, passkeys, + accountAuthorizations, }: AccountProps) => { const createdAtDate = getFormattedDate(createdAt); const disabledAtDate = getFormattedDate(disabledAt); @@ -417,6 +419,9 @@ export const Account = ({

Connected Services

+ +

Authorized Browser Services

+

Account History

diff --git a/packages/fxa-admin-panel/src/components/PageAccountSearch/AccountAuthorizations/index.test.tsx b/packages/fxa-admin-panel/src/components/PageAccountSearch/AccountAuthorizations/index.test.tsx new file mode 100644 index 00000000000..cb823463f97 --- /dev/null +++ b/packages/fxa-admin-panel/src/components/PageAccountSearch/AccountAuthorizations/index.test.tsx @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { render, screen } from '@testing-library/react'; +import { AccountAuthorization } from 'fxa-admin-server/src/types'; +import { AccountAuthorizations } from '.'; + +const AUTHORIZATIONS: AccountAuthorization[] = [ + { + service: 'sync', + scope: 'https://identity.mozilla.com/apps/oldsync', + authorizedAt: new Date('2026-01-01T00:00:00Z').getTime(), + }, + { + service: 'relay', + scope: 'https://identity.mozilla.com/apps/relay', + authorizedAt: new Date('2026-02-01T00:00:00Z').getTime(), + }, +]; + +it('renders the empty state when there are no authorizations', () => { + render(); + expect(screen.getByTestId('account-authorizations-none')).toHaveTextContent( + 'This account has not authorized any browser services.' + ); +}); + +it('renders the empty state when authorizations is null', () => { + render(); + expect(screen.getByTestId('account-authorizations-none')).toBeInTheDocument(); +}); + +it('renders one row per authorization', () => { + render(); + const services = screen.getAllByTestId('account-authorization-service'); + const scopes = screen.getAllByTestId('account-authorization-scope'); + const dates = screen.getAllByTestId('account-authorization-authorized-at'); + + expect(services).toHaveLength(2); + expect(scopes).toHaveLength(2); + expect(dates).toHaveLength(2); + + expect(services[0]).toHaveTextContent('sync'); + expect(scopes[0]).toHaveTextContent( + 'https://identity.mozilla.com/apps/oldsync' + ); + expect(services[1]).toHaveTextContent('relay'); +}); diff --git a/packages/fxa-admin-panel/src/components/PageAccountSearch/AccountAuthorizations/index.tsx b/packages/fxa-admin-panel/src/components/PageAccountSearch/AccountAuthorizations/index.tsx new file mode 100644 index 00000000000..1663d969b0a --- /dev/null +++ b/packages/fxa-admin-panel/src/components/PageAccountSearch/AccountAuthorizations/index.tsx @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AccountAuthorization } from 'fxa-admin-server/src/types'; +import { TableRowXHeader, TableXHeaders } from '../../TableXHeaders'; +import { getFormattedDate } from '../../../lib/utils'; + +export const AccountAuthorizations = ({ + authorizations, +}: { + authorizations?: Nullable; +}) => { + if (!authorizations || authorizations.length === 0) { + return ( +

+ This account has not authorized any browser services. +

+ ); + } + + return ( + + {authorizations.map(({ service, scope, authorizedAt }) => ( + + {service} + {scope} + + {getFormattedDate(authorizedAt)} + + + ))} + + ); +}; diff --git a/packages/fxa-admin-server/src/database/database.service.spec.ts b/packages/fxa-admin-server/src/database/database.service.spec.ts index 8a7b961a2bc..10a51fdcdb6 100644 --- a/packages/fxa-admin-server/src/database/database.service.spec.ts +++ b/packages/fxa-admin-server/src/database/database.service.spec.ts @@ -18,6 +18,18 @@ describe('#integration - DatabaseService', () => { beforeAll(async () => { knex = await testDatabaseSetup(); + // Ensure the table exists in the test DB. The shared oauth fixture set + // is built once and cached; adding a new fixture file there can be + // skipped by stale build caches, so create the table inline here. + await knex.raw( + 'CREATE TABLE IF NOT EXISTS `accountAuthorizations` (' + + '`uid` BINARY(16) NOT NULL,' + + '`scope` VARCHAR(512) NOT NULL,' + + '`service` VARCHAR(64) NOT NULL,' + + '`authorizedAt` BIGINT UNSIGNED NOT NULL,' + + 'PRIMARY KEY (`uid`, `scope`, `service`)' + + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); }); afterAll(async () => { @@ -89,4 +101,46 @@ describe('#integration - DatabaseService', () => { it('should be able to invoke attachedDevices', async () => { await service.attachedDevices('AB12'); }); + + it('returns rows ordered by authorizedAt descending', async () => { + const uid = 'aabbccddeeff00112233445566778899'; + const uidBuffer = Buffer.from(uid, 'hex'); + const now = Date.now(); + await service.knexOauth('accountAuthorizations').insert([ + { + uid: uidBuffer, + scope: 'https://identity.mozilla.com/apps/oldsync', + service: 'sync', + authorizedAt: now - 1000, + }, + { + uid: uidBuffer, + scope: 'https://identity.mozilla.com/apps/relay', + service: 'relay', + authorizedAt: now, + }, + ]); + + const rows = await service.accountAuthorizations(uid); + + expect(rows).toEqual([ + { + scope: 'https://identity.mozilla.com/apps/relay', + service: 'relay', + authorizedAt: now, + }, + { + scope: 'https://identity.mozilla.com/apps/oldsync', + service: 'sync', + authorizedAt: now - 1000, + }, + ]); + }); + + it('returns an empty array when the uid has no authorizations', async () => { + const rows = await service.accountAuthorizations( + '00000000000000000000000000000001' + ); + expect(rows).toEqual([]); + }); }); diff --git a/packages/fxa-admin-server/src/database/database.service.ts b/packages/fxa-admin-server/src/database/database.service.ts index 62277452738..b185fafa371 100644 --- a/packages/fxa-admin-server/src/database/database.service.ts +++ b/packages/fxa-admin-server/src/database/database.service.ts @@ -33,6 +33,8 @@ import { MozLoggerService } from '@fxa/shared/mozlog'; import { StatsD } from 'hot-shots'; import { Knex, knex } from 'knex'; import { AppConfig } from '../config'; +import { AccountAuthorization } from '../types'; +import { uuidTransformer } from './transformers'; function typeCasting(field: any, next: any) { if (field.type === 'TINY' && field.length === 1) { @@ -137,6 +139,22 @@ export class DatabaseService implements OnModuleDestroy { return mergeCachedSessionTokens(dbSessionTokens, cachedSessionTokens, true); } + public async accountAuthorizations( + uid: string + ): Promise { + const uidBuffer = uuidTransformer.to(uid); + const rows = await this.knexOauth('accountAuthorizations') + .select('scope', 'service', 'authorizedAt') + .where('uid', uidBuffer) + .orderBy('authorizedAt', 'desc') + .limit(10); + return rows.map((row) => ({ + scope: row.scope, + service: row.service, + authorizedAt: Number(row.authorizedAt), + })); + } + public async attachedDevices(uid: string) { const [devices, sessionTokens] = await Promise.all([ this.device.findByUid(uid), diff --git a/packages/fxa-admin-server/src/rest/account/account.controller.ts b/packages/fxa-admin-server/src/rest/account/account.controller.ts index ebff2c923ec..e6b675a8c4b 100644 --- a/packages/fxa-admin-server/src/rest/account/account.controller.ts +++ b/packages/fxa-admin-server/src/rest/account/account.controller.ts @@ -58,6 +58,7 @@ import { BasketService } from '../../newsletters/basket.service'; import { FidoMdsService } from '../../backend/fido-mds.service'; import { SubscriptionsService } from '../../subscriptions/subscriptions.service'; import { + AccountAuthorization, AccountDeleteResponse, AccountDeleteStatus, AccountDeleteTaskStatus, @@ -187,6 +188,7 @@ export class AccountController { linkedAccounts, attachedClients, passkeys, + accountAuthorizations, ] = await Promise.all([ this.emails(account), this.emailBounces(account), @@ -201,6 +203,7 @@ export class AccountController { this.linkedAccounts(account), this.attachedClients(account), this.passkeys(account), + this.accountAuthorizations(account), ]); return { @@ -218,6 +221,7 @@ export class AccountController { linkedAccounts, attachedClients, passkeys, + accountAuthorizations, }; } @@ -919,6 +923,13 @@ export class AccountController { ); } + @Features(AdminPanelFeature.ConnectedServices) + public async accountAuthorizations( + account: Account + ): Promise { + return this.db.accountAuthorizations(account.uid); + } + @Features(AdminPanelFeature.ConnectedServices) public async attachedClients(account: Account) { const clientFormatter = new ClientFormatter( diff --git a/packages/fxa-admin-server/src/types.ts b/packages/fxa-admin-server/src/types.ts index 0277af65d87..e0897059c90 100644 --- a/packages/fxa-admin-server/src/types.ts +++ b/packages/fxa-admin-server/src/types.ts @@ -111,6 +111,12 @@ export interface Passkey { prfEnabled: boolean; } +export interface AccountAuthorization { + scope: string; + service: string; + authorizedAt: number; +} + export interface LinkedAccount { uid?: string; authAt?: number; @@ -209,6 +215,7 @@ export interface Account { backupCodes: BackupCodes[]; recoveryPhone: RecoveryPhone[]; passkeys: Passkey[]; + accountAuthorizations: AccountAuthorization[]; } export interface RelyingPartyUpdateDto {