From a79d95281cc11d0c659afbbeb06dff30d853fce1 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 18:13:58 +1000 Subject: [PATCH 1/4] feat(workplace): run as a Teams and Microsoft 365 personal tab Load teams-js only when the tab URL has `?host=teams`. Sign in through Teams SSO, then a sign-in window, then a Sign in button, because the Microsoft login page cannot load in the host frame. Add the app package template, a packaging script and docs/teams-app.md. --- AGENTS.md | 1 + apps/workplace/src/app/app.component.ts | 27 ++++ apps/workplace/teams/color.png | Bin 0 -> 32353 bytes apps/workplace/teams/manifest.json | 39 ++++++ apps/workplace/teams/outline.png | Bin 0 -> 448 bytes apps/workplace/teams/package.ts | 98 +++++++++++++ bun.lock | 5 + docs/teams-app.md | 93 +++++++++++++ libs/common/src/index.ts | 1 + libs/common/src/lib/placeos.service.ts | 9 ++ libs/common/src/lib/teams-host.spec.ts | 148 ++++++++++++++++++++ libs/common/src/lib/teams-host.ts | 177 ++++++++++++++++++++++++ package.json | 1 + 13 files changed, 599 insertions(+) create mode 100644 apps/workplace/teams/color.png create mode 100644 apps/workplace/teams/manifest.json create mode 100644 apps/workplace/teams/outline.png create mode 100644 apps/workplace/teams/package.ts create mode 100644 docs/teams-app.md create mode 100644 libs/common/src/lib/teams-host.spec.ts create mode 100644 libs/common/src/lib/teams-host.ts diff --git a/AGENTS.md b/AGENTS.md index f69a055d066..643fc077adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,3 +15,4 @@ - [Mobile builds](docs/mobile-builds.md): Read before changing or running a Capacitor workflow. - [Release workflow](docs/release-workflow.md): Read before creating a pull request or changing release and deployment configuration. - [Signage plugin thumbnails](docs/signage-plugin-thumbnails.md): Read before changing the signage plugin thumbnail protocol. +- [Teams and Microsoft 365 app](docs/teams-app.md): Read before changing the Teams app package or the Teams host sign-in. diff --git a/apps/workplace/src/app/app.component.ts b/apps/workplace/src/app/app.component.ts index efcc2e86318..648db1bb6e8 100644 --- a/apps/workplace/src/app/app.component.ts +++ b/apps/workplace/src/app/app.component.ts @@ -1,9 +1,11 @@ import { Component, inject, OnInit } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { + continueTeamsSignIn, PlaceOS_Service, setMocks, settingSignal, + teamsSignInRequired, UploadsService, watchUserGroupSync, } from '@placeos/common'; @@ -33,6 +35,25 @@ import { mocksInit } from '@placeos/mocks'; } + @if (teams_sign_in_required()) { +
+
+

Sign in to continue.

+ +
+
+ } `, styles: [ @@ -51,6 +72,8 @@ export class AppComponent implements OnInit { private _uploads = inject(UploadsService); public readonly has_chat = settingSignal('chat.enabled', false); + /** Set when a Teams or Microsoft 365 host needs a click to open sign in */ + public readonly teams_sign_in_required = teamsSignInRequired(); public readonly load_settings_schema = () => import('../environments/settings.schema.json'); @@ -64,4 +87,8 @@ export class AppComponent implements OnInit { await this._placeos.init(); if (this._placeos.has_uploads) this._uploads.init(); } + + public signInWithTeams() { + continueTeamsSignIn(); + } } diff --git a/apps/workplace/teams/color.png b/apps/workplace/teams/color.png new file mode 100644 index 0000000000000000000000000000000000000000..2af0db3a6d9857066ddba3d6fed091f1f1d2e15b GIT binary patch literal 32353 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 MzNv15wsYM70iCFl=Kufz literal 0 HcmV?d00001 diff --git a/apps/workplace/teams/manifest.json b/apps/workplace/teams/manifest.json new file mode 100644 index 00000000000..b80ae1fd237 --- /dev/null +++ b/apps/workplace/teams/manifest.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.30/MicrosoftTeams.schema.json", + "manifestVersion": "1.30", + "version": "1.0.0", + "id": "{{APP_ID}}", + "developer": { + "name": "PlaceOS", + "websiteUrl": "https://place.technology/", + "privacyUrl": "https://place.technology/", + "termsOfUseUrl": "https://place.technology/" + }, + "name": { + "short": "Workplace", + "full": "PlaceOS Workplace" + }, + "description": { + "short": "Book spaces, desks and parking, and find people in your workplace.", + "full": "PlaceOS Workplace in Microsoft Teams, Outlook and the Microsoft 365 app. Book spaces, desks and parking, manage your bookings and find people in your workplace." + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "accentColor": "#0F766E", + "staticTabs": [ + { + "entityId": "workplace", + "name": "Workplace", + "contentUrl": "https://{{HOST}}{{PATH}}?host=teams", + "websiteUrl": "https://{{HOST}}{{PATH}}", + "scopes": ["personal"] + } + ], + "validDomains": ["{{HOST}}"], + "webApplicationInfo": { + "id": "{{CLIENT_ID}}", + "resource": "api://{{HOST}}/{{CLIENT_ID}}" + } +} diff --git a/apps/workplace/teams/outline.png b/apps/workplace/teams/outline.png new file mode 100644 index 0000000000000000000000000000000000000000..091ec37ab3b72538318eac4a44e2800eb9632205 GIT binary patch literal 448 zcmV;x0YCnUP)CPIBG`#(TuJApQlgMDMbf1b(SVoNurW4b zx6Z;J7{;?}&PmR0z=4mzoH;We?1W^UAPAf^aVAkJ=&u8sY>9H6|^lZT!;04Pzmsbt5^`Iu>M~v z0Umo61UxZgXn`f-3Xg?TCl*0naej%rMgktow_-NhS1d1&7VG1h3f#mDsDh}tHWhGL zex4{22Le|#m*+*?9n=&|1;&Mzz^(;>ypBMD|Qnv&wYjJrkxd6sX+ELjai zMU(Z(nF=&w1{{K@XqXD@uzZF%Aua^r*KZC~#o0OTWLTeV9f6#MB^;Z;3b6^Y#0EaI z^3R4Xfo{w*@BTZ1caWOE9Y{^!77Qb>r6W+ocB^<}b@yNpuf#gBCRinobp(=#UjqFE qhkvL9!e6k@1cvu9YjR&s6XgeC9jphX@XHeb0000 [--client-id ] [--path /workplace/] + * ``` + * + * Writes `dist/teams/workplace-/` and `dist/teams/workplace-.zip`. + * Without `--client-id` the package has no SSO, so the tab always signs in + * with the sign-in window. + */ +import { spawnSync } from 'node:child_process'; +import { + copyFileSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HOSTNAME = /^[a-z0-9.-]+(:\d+)?$/i; +const ICONS = ['color.png', 'outline.png']; + +const template_dir = fileURLToPath(new URL('.', import.meta.url)); +const { values: args } = parseArgs({ + options: { + host: { type: 'string' }, + 'app-id': { type: 'string' }, + 'client-id': { type: 'string' }, + path: { type: 'string', default: '/workplace/' }, + out: { type: 'string', default: 'dist/teams' }, + }, +}); + +function fail(message: string): never { + console.error( + `${message}\nSee the usage in ${join(template_dir, 'package.ts')}.`, + ); + process.exit(1); +} + +const host = args.host || fail('Missing --host.'); +const app_id = args['app-id'] || fail('Missing --app-id.'); +const client_id = args['client-id'] || ''; +const path = args.path; +if (!HOSTNAME.test(host)) + fail('--host must be a host name, without a protocol or path.'); +if (!GUID.test(app_id)) fail('--app-id must be a GUID.'); +if (client_id && !GUID.test(client_id)) fail('--client-id must be a GUID.'); +if (!path.startsWith('/') || !path.endsWith('/')) + fail('--path must start and end with "/".'); + +const values: Record = { + HOST: host, + APP_ID: app_id, + CLIENT_ID: client_id, + PATH: path, +}; +const text = readFileSync(join(template_dir, 'manifest.json'), 'utf8').replace( + /\{\{(\w+)\}\}/g, + (match, key: string) => values[key] ?? match, +); +const manifest = JSON.parse(text); +if (!client_id) delete manifest.webApplicationInfo; +const output = JSON.stringify(manifest, null, 4); +if (output.includes('{{')) + fail('The manifest has a placeholder with no value.'); + +const name = `workplace-${host.replace(/[^a-z0-9.-]/gi, '-')}`; +const out_dir = resolve(args.out, name); +const zip_file = resolve(args.out, `${name}.zip`); +rmSync(out_dir, { recursive: true, force: true }); +rmSync(zip_file, { force: true }); +mkdirSync(out_dir, { recursive: true }); +writeFileSync(join(out_dir, 'manifest.json'), `${output}\n`); +for (const icon of ICONS) + copyFileSync(join(template_dir, icon), join(out_dir, icon)); + +// The package must have the files at the root of the zip, with no folders. +const zip = spawnSync( + 'zip', + ['-j', '-X', '-q', zip_file, 'manifest.json', ...ICONS], + { + cwd: out_dir, + stdio: 'inherit', + }, +); +if (zip.error || zip.status !== 0) + fail( + 'The "zip" command failed. Install zip, or zip the output folder by hand.', + ); +console.info(`Wrote ${zip_file}`); diff --git a/bun.lock b/bun.lock index 4902dd3f1f7..83782513bd6 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "@angular/router": "22.1.5", "@angular/service-worker": "22.1.5", "@capacitor/core": "8.2.0", + "@microsoft/teams-js": "^2.57.0", "@nx/angular": "23.2.0", "@sentry/cli": "^2.28.6", "@types/google.maps": "^3.55.0", @@ -749,6 +750,8 @@ "@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-1g7G0knRX2iV/voDu54yxrGqw5Dk0w2oIYb7dgJq8IkOi+m7wbD8Q3QpPFjh0C01G58S88dqGn03len6UPCXsg=="], + "@microsoft/teams-js": ["@microsoft/teams-js@2.57.0", "", { "dependencies": { "base64-js": "^1.3.1", "debug": "^4.3.3" } }, "sha512-HJPyDD52nzs5E4WeScncMWHBDOQTSjtF1s3QFYTEGqVp+yqgRAJ1nTrcHyYw9TARKFg8y05OQLCOSRqM656wQw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], @@ -3455,6 +3458,8 @@ "@jest/transform/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "@microsoft/teams-js/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "@nx/angular/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "@nx/angular/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], diff --git a/docs/teams-app.md b/docs/teams-app.md new file mode 100644 index 00000000000..64b08b11540 --- /dev/null +++ b/docs/teams-app.md @@ -0,0 +1,93 @@ +# Teams and Microsoft 365 app + +The workplace app can run as a personal tab in Microsoft Teams, Outlook and the Microsoft 365 app. The app package in `apps/workplace/teams/` adds the tab. The tab loads the workplace app from the PlaceOS host. + +## How the tab works + +1. The tab URL has the query `?host=teams`. Without this query, the app starts as a normal web app. +2. With the query, the app loads `@microsoft/teams-js` and calls `app.initialize()`. If the host does not answer in 5 seconds, the app starts as a normal web app. +3. The tab frame cannot show the Microsoft login page. Thus the app does not redirect to the PlaceOS login page in the tab. +4. If the tab has no PlaceOS token, the app gets a token in this sequence: + 1. Single sign-on (SSO) with `authentication.getAuthToken()`. + 2. A sign-in window from `authentication.authenticate()`. This window loads the app with `?host=teams-auth` and runs the normal PlaceOS login. Then it sends the PlaceOS token to the tab. + 3. If a host blocks the sign-in window, the tab shows a **Sign in** button. The user selects the button to open the window again. + +The code is in `libs/common/src/lib/teams-host.ts`. `PlaceOS_Service.init()` calls it. The **Sign in** button is in `apps/workplace/src/app/app.component.ts`. + +## Build the app package + +Run this command from the repository root: + +```sh +bun apps/workplace/teams/package.ts --host example.placeos.com --app-id --client-id +``` + +| Option | Description | +| ------------- | ----------------------------------------------------------------------------------------------------------- | +| `--host` | Host name of the PlaceOS server. Do not include the protocol or a path. | +| `--app-id` | Teams app ID. Make a new GUID for each package. Keep the same GUID for updates to the same package. | +| `--client-id` | Optional. Client ID of the Microsoft Entra app for SSO. Without it, the tab always uses the sign-in window. | +| `--path` | Optional. Path of the workplace app on the host. The default is `/workplace/`. | +| `--out` | Optional. Output folder. The default is `dist/teams`. | + +The script writes the package to `dist/teams/workplace-.zip`. The script uses the `zip` command. + +The template is `apps/workplace/teams/manifest.json`. It uses app manifest schema 1.30. Microsoft 365 hosts need schema 1.13 or later. + +## Register the Microsoft Entra app for SSO + +Do these steps only if you use SSO. + +1. In the Azure portal, open **App registrations** and create a registration. +2. Open **Expose an API**. +3. Set the **Application ID URI** to `api:///`. +4. Add the scope `access_as_user`. Let admins and users give consent. +5. In **Authorized client applications**, add all of these client IDs with the `access_as_user` scope: + + | Client application | Client ID | + | ------------------------------------- | -------------------------------------- | + | Teams desktop, mobile | `1fec8e78-bce4-4aaf-ab1b-5451cc387264` | + | Teams web | `5e3ce6c0-2b1f-4285-8d4b-75ee78787346` | + | Microsoft 365 web | `4765445b-32c6-49b0-83e6-1d93765276ca` | + | Microsoft 365 desktop | `0ec893e0-5785-4de6-99da-4ed124e5296c` | + | Microsoft 365 mobile, Outlook desktop | `d3590ed6-52b3-4102-aeff-aad2292ab01c` | + | Outlook web | `bc59ab01-8403-45c6-8796-ac3ef710b3e3` | + | Outlook mobile | `27922004-5251-4030-b22d-91ecd9a37ea4` | + +6. Use the application (client) ID as `--client-id`. + +The source of the client IDs is [Extend a Teams personal tab across Microsoft 365](https://learn.microsoft.com/en-us/microsoftteams/platform/m365-apps/extend-m365-teams-personal-tab). + +**Warning:** The app sends the SSO token to PlaceOS as the bearer token. The PlaceOS backend must accept Microsoft Entra tokens for this app registration. The `aud` claim of the token is the client ID or the Application ID URI. If the backend does not accept the token, the app cannot load the user. In this condition, build the package without `--client-id`. + +## Configure the PlaceOS host + +The hosts show the tab in a frame. The PlaceOS host must let these hosts frame the workplace app. + +1. Remove the `X-Frame-Options` header from the workplace app responses, or do not set it to `DENY` or `SAMEORIGIN`. +2. Add these values to the `frame-ancestors` directive of the `Content-Security-Policy` header: + +```http +Content-Security-Policy: frame-ancestors 'self' https://*.cloud.microsoft https://teams.microsoft.com https://*.teams.microsoft.com https://*.microsoft365.com https://*.office.com https://outlook.office.com https://outlook.office365.com https://outlook-sdf.office.com https://outlook-sdf.office365.com +``` + +Microsoft moves its web hosts to `*.cloud.microsoft`. Keep all of the values. + +## Deploy the app + +1. In the Teams admin center, open **Teams apps** > **Manage apps**. +2. Select **Upload new app** and upload the package. +3. Make sure that the app permission policies allow the app. +4. To pin the app in Teams, open **Teams apps** > **Setup policies**. Add the app to **Pinned apps**, then assign the policy to users. + +The app is then also available in Outlook and the Microsoft 365 app, in **More apps**. A pin in Teams does not pin the app in Outlook or the Microsoft 365 app. You can also deploy the app from the Microsoft 365 admin center, in **Settings** > **Integrated apps**. + +For a test, a user can upload the package in Teams from **Apps** > **Manage your apps** > **Upload an app**. + +## Known limits + +- **Token expiry:** The app does not refresh a token that it got from the host. When the token expires, API requests fail. Reload the tab to get a new token. With SSO, this is silent. +- **Sign out:** Sign out goes to the PlaceOS logout page in the tab frame. This can fail in the frame. +- **External links:** `libs/components/src/lib/user-controls.component.ts` and `libs/explore/src/lib/explore-spaces.service.ts` use `window.open()`. Web hosts usually open a new browser tab. Desktop and mobile hosts are not validated. A follow-up can use `app.openLink()` from teams-js in the host. +- **Classic Outlook for Windows:** Support for Microsoft 365 personal tabs in classic Outlook is not validated. +- **Storage:** In web hosts, the browser can partition the storage of the tab frame. The tab keeps its own copy of the token. Thus a sign-in in a normal browser tab does not sign in the Teams tab. diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index cef66120ad0..f60e6d87613 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -21,6 +21,7 @@ export * from './lib/remote-logging.service'; export * from './lib/settings.service'; export * from './lib/settings-title-strategy.service'; export * from './lib/shorten.fn'; +export * from './lib/teams-host'; export * from './lib/timezone-helpers'; export * from './lib/uploads.service'; export * from './lib/user-group-sync'; diff --git a/libs/common/src/lib/placeos.service.ts b/libs/common/src/lib/placeos.service.ts index a3a676b5392..9004dc94399 100644 --- a/libs/common/src/lib/placeos.service.ts +++ b/libs/common/src/lib/placeos.service.ts @@ -60,6 +60,7 @@ import { notifySuccess, setNotifyOutlet } from './notifications'; import { OrganisationService } from './org/organisation.service'; import { createNativeAuthUrl, setupPlace } from './placeos'; import { SettingsService } from './settings.service'; +import { finishTeamsSignIn, startTeamsHost } from './teams-host'; import { setInternalUserDomain } from './types/user.class'; import { current_user, currentUser } from './user-state'; @@ -457,6 +458,10 @@ export class PlaceOS_Service extends AsyncHandler { return; } if (!isNativeApp()) { + // A Teams or Microsoft 365 tab cannot show the Microsoft login + // page in its frame, so it signs in through the host instead. + const teams_mode = await startTeamsHost(); + if (teams_mode) settings.local_login = teams_mode === 'tab'; setLoadingMessage('Authenticating...'); // `setup` resolves only once the authority has loaded, and it never // rejects - a failure retries in the background forever. Waiting on @@ -467,6 +472,10 @@ export class PlaceOS_Service extends AsyncHandler { await setupPlace(settings, AUTHORITY_WAIT_MS).catch((_) => console.error(_), ); + if (teams_mode) { + setLoadingMessage('Waiting for Microsoft sign in...'); + if (!(await finishTeamsSignIn(teams_mode))) return; + } } if (this._initial_token) setToken(this._initial_token); try { diff --git a/libs/common/src/lib/teams-host.spec.ts b/libs/common/src/lib/teams-host.spec.ts new file mode 100644 index 00000000000..0acd74a0f10 --- /dev/null +++ b/libs/common/src/lib/teams-host.spec.ts @@ -0,0 +1,148 @@ +vi.mock('@placeos/ts-client', { spy: true }); +vi.mock('@microsoft/teams-js', () => ({ + app: { initialize: vi.fn() }, + authentication: { + getAuthToken: vi.fn(), + authenticate: vi.fn(), + notifySuccess: vi.fn(), + }, +})); + +import * as teams from '@microsoft/teams-js'; +import * as ts_client from '@placeos/ts-client'; + +import { + continueTeamsSignIn, + finishTeamsSignIn, + startTeamsHost, + teamsHostMode, + teamsSignInRequired, + tokenExpiry, +} from './teams-host'; + +// Pick the promise overloads; vi.mocked uses the last (callback) overload. +const getAuthToken = vi.mocked<() => Promise>( + teams.authentication.getAuthToken, +); +const authenticate = vi.mocked< + ( + params: teams.authentication.AuthenticatePopUpParameters, + ) => Promise +>(teams.authentication.authenticate); + +/** Unsigned JWT with the given `exp` claim in seconds */ +function jwt(exp: number) { + const payload = btoa(JSON.stringify({ exp })).replace(/=+$/, ''); + return `header.${payload}.signature`; +} + +describe('teamsHostMode', () => { + const no_store = { getItem: () => null }; + + it('is null for a normal web launch', () => { + expect(teamsHostMode('', no_store)).toBeNull(); + expect(teamsHostMode('?host=other', no_store)).toBeNull(); + }); + + it('reads the tab and sign-in window flags from the query', () => { + expect(teamsHostMode('?host=teams', no_store)).toBe('tab'); + expect(teamsHostMode('?mock=true&host=teams-auth', no_store)).toBe( + 'auth', + ); + }); + + it('keeps the sign-in window mode after the login redirect drops the query', () => { + expect(teamsHostMode('', { getItem: () => 'true' })).toBe('auth'); + }); +}); + +describe('tokenExpiry', () => { + it('reads exp from a JWT, 5 minutes early', () => { + expect(tokenExpiry(jwt(2_000_000_000))).toBe( + 2_000_000_000_000 - 5 * 60 * 1000, + ); + }); + + it('is undefined for other tokens', () => { + expect(tokenExpiry('opaque-token')).toBeUndefined(); + expect(tokenExpiry('a.not-base64!.c')).toBeUndefined(); + }); +}); + +describe('Teams host startup', () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + vi.mocked(teams.app.initialize).mockResolvedValue(undefined); + vi.mocked(ts_client.token).mockReturnValue(''); + vi.mocked(ts_client.setToken).mockImplementation(() => undefined); + }); + + afterEach(() => vi.useRealTimers()); + + it('does not contact a host for a normal web launch', async () => { + expect(await startTeamsHost(null)).toBeNull(); + expect(teams.app.initialize).not.toHaveBeenCalled(); + }); + + it('continues as a normal web launch when the host does not answer', async () => { + vi.useFakeTimers(); + vi.mocked(teams.app.initialize).mockReturnValue(new Promise(() => {})); + const mode = startTeamsHost('tab'); + await vi.advanceTimersByTimeAsync(5_000); + expect(await mode).toBeNull(); + }); + + it('uses the SSO token without a sign-in window', async () => { + const sso = jwt(2_000_000_000); + getAuthToken.mockResolvedValue(sso); + await startTeamsHost('tab'); + + expect(await finishTeamsSignIn('tab')).toBe(true); + expect(ts_client.setToken).toHaveBeenCalledWith(sso, tokenExpiry(sso)); + expect(authenticate).not.toHaveBeenCalled(); + }); + + it('opens the sign-in window when SSO fails, then waits for a click after a blocked window', async () => { + getAuthToken.mockRejectedValue(new Error('resourceRequiresConsent')); + authenticate + .mockRejectedValueOnce(new Error('FailedToOpenWindow')) + .mockResolvedValueOnce('place-token'); + await startTeamsHost('tab'); + + const signed_in = finishTeamsSignIn('tab'); + await vi.waitFor(() => expect(teamsSignInRequired()()).toBe(true)); + expect(authenticate).toHaveBeenCalledTimes(1); + expect(authenticate.mock.calls[0][0]).toMatchObject({ + url: expect.stringContaining('?host=teams-auth'), + }); + + continueTeamsSignIn(); + expect(await signed_in).toBe(true); + expect(teamsSignInRequired()()).toBe(false); + expect(ts_client.setToken).toHaveBeenCalledWith( + 'place-token', + undefined, + ); + }); + + it('skips host sign in when the tab already has a token', async () => { + vi.mocked(ts_client.token).mockReturnValue('stored-token'); + await startTeamsHost('tab'); + + expect(await finishTeamsSignIn('tab')).toBe(true); + expect(getAuthToken).not.toHaveBeenCalled(); + }); + + it('sends the PlaceOS token from the sign-in window to the tab', async () => { + await startTeamsHost('auth'); + expect(sessionStorage.getItem('PLACEOS.teams_auth')).toBe('true'); + vi.mocked(ts_client.token).mockReturnValue('place-token'); + + expect(await finishTeamsSignIn('auth')).toBe(false); + expect(teams.authentication.notifySuccess).toHaveBeenCalledWith( + 'place-token', + ); + expect(sessionStorage.getItem('PLACEOS.teams_auth')).toBeNull(); + }); +}); diff --git a/libs/common/src/lib/teams-host.ts b/libs/common/src/lib/teams-host.ts new file mode 100644 index 00000000000..e81f6bf3418 --- /dev/null +++ b/libs/common/src/lib/teams-host.ts @@ -0,0 +1,177 @@ +import { signal } from '@angular/core'; +import { setToken, token } from '@placeos/ts-client'; + +import { failInitialisation } from './application'; +import { log, withTimeout } from './general'; + +/** + * Microsoft Teams and Microsoft 365 (Outlook, Microsoft 365 app) personal tab + * support. See docs/teams-app.md. + * + * The app package opens the tab with `?host=teams`. Only then does the app + * load `@microsoft/teams-js`, so normal web startup does not load it. + * + * The tab runs in a frame that cannot show the Microsoft login page. When the + * tab has no PlaceOS token it signs in through the host: + * 1. Silent SSO with `authentication.getAuthToken()`. + * 2. Else a sign-in window from `authentication.authenticate()`. That window + * loads the app with `?host=teams-auth`, runs the normal PlaceOS login and + * sends the PlaceOS token back with `authentication.notifySuccess()`. + * + * Usage, around `setupPlace` during startup: + * ```ts + * const mode = await startTeamsHost(); + * if (mode) settings.local_login = mode === 'tab'; + * await setupPlace(settings); + * if (mode && !(await finishTeamsSignIn(mode))) return; + * ``` + */ + +type TeamsJs = typeof import('@microsoft/teams-js'); + +/** `tab` is the frame in the host. `auth` is the sign-in window. */ +export type TeamsHostMode = 'tab' | 'auth'; + +/** Query parameter on the tab URL that selects the host mode */ +export const TEAMS_HOST_PARAM = 'host'; +/** Keeps the sign-in window mode when the login redirect drops the query */ +const AUTH_STORE_KEY = 'PLACEOS.teams_auth'; +const INIT_TIMEOUT_MS = 5_000; +const SSO_TIMEOUT_MS = 10_000; +/** Sign-in window attempts before startup fails */ +const MAX_SIGN_IN_ATTEMPTS = 5; +/** Match ts-client, which expires its own tokens 5 minutes early */ +const EXPIRY_MARGIN_MS = 5 * 60 * 1000; + +let _teams: TeamsJs | null = null; +let _sign_in_resolve: (() => void) | null = null; +const SIGN_IN_REQUIRED = signal(false); + +/** True while the tab waits for the user to select the sign-in button. */ +export function teamsSignInRequired() { + return SIGN_IN_REQUIRED.asReadonly(); +} + +/** + * Open the sign-in window from a click. Some hosts block the window when it + * does not open from a user gesture. + */ +export function continueTeamsSignIn(): void { + SIGN_IN_REQUIRED.set(false); + _sign_in_resolve?.(); + _sign_in_resolve = null; +} + +/** Host mode from the launch URL, or null for a normal web launch. */ +export function teamsHostMode( + search = location.search, + storage: Pick = sessionStorage, +): TeamsHostMode | null { + const host = new URLSearchParams(search).get(TEAMS_HOST_PARAM); + if (host === 'teams-auth' || storage.getItem(AUTH_STORE_KEY) === 'true') { + return 'auth'; + } + return host === 'teams' ? 'tab' : null; +} + +/** + * Load teams-js and connect to the host. Resolves to null for a normal web + * launch, or when the host does not answer, so startup continues as normal. + */ +export async function startTeamsHost( + mode = teamsHostMode(), +): Promise { + if (!mode) return null; + if (mode === 'auth') sessionStorage.setItem(AUTH_STORE_KEY, 'true'); + try { + const teams = await import('@microsoft/teams-js'); + await withTimeout( + teams.app.initialize(), + INIT_TIMEOUT_MS, + 'Microsoft Teams host did not respond.', + ); + _teams = teams; + log('Teams', `Connected to host as ${mode}.`); + return mode; + } catch (error) { + log('Teams', 'Host is not available.', error, 'warn'); + return null; + } +} + +/** + * Finish sign in after `setupPlace`, so tokens use the computed client ID. + * Resolves to false when startup must stop: the sign-in window is done or + * redirecting, or the tab could not sign in. + */ +export async function finishTeamsSignIn(mode: TeamsHostMode): Promise { + if (!_teams) throw new Error('Call startTeamsHost() first.'); + if (mode === 'auth') { + // No token means ts-client is redirecting to the login page. + const place_token = token(false); + if (place_token) { + sessionStorage.removeItem(AUTH_STORE_KEY); + _teams.authentication.notifySuccess(place_token); + } + return false; + } + if (token(false)) return true; + if (await signInTeamsTab(_teams)) return true; + failInitialisation('Sign in did not finish. Select Try again to sign in.'); + return false; +} + +async function signInTeamsTab(teams: TeamsJs): Promise { + const sso_token = await withTimeout( + teams.authentication.getAuthToken(), + SSO_TIMEOUT_MS, + 'Microsoft single sign-on timed out.', + ).catch((error) => { + log('Teams', 'SSO failed.', error, 'warn'); + return ''; + }); + if (sso_token) { + setToken(sso_token, tokenExpiry(sso_token)); + return true; + } + const url = `${location.origin}${location.pathname}?${TEAMS_HOST_PARAM}=teams-auth`; + for (let attempt = 0; attempt < MAX_SIGN_IN_ATTEMPTS; attempt++) { + // Try without a click first. Later attempts wait for the button. + if (attempt > 0) { + await new Promise((resolve) => { + _sign_in_resolve = resolve; + SIGN_IN_REQUIRED.set(true); + }); + } + const place_token = await teams.authentication + .authenticate({ url, width: 600, height: 600 }) + .catch((error) => { + log('Teams', 'Sign-in window failed.', error, 'warn'); + return ''; + }); + if (place_token) { + setToken(place_token, tokenExpiry(place_token)); + return true; + } + } + return false; +} + +/** + * Expiry in milliseconds from the `exp` claim of a JWT. Undefined when the + * token is not a JWT, so ts-client uses its default expiry. + */ +export function tokenExpiry(jwt: string): number | undefined { + try { + const payload = jwt.split('.')[1]; + if (!payload) return undefined; + const claims = JSON.parse( + atob(payload.replace(/-/g, '+').replace(/_/g, '/')), + ); + return typeof claims.exp === 'number' + ? claims.exp * 1000 - EXPIRY_MARGIN_MS + : undefined; + } catch { + return undefined; + } +} diff --git a/package.json b/package.json index 7d37f9bfabe..b101befbf38 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@angular/router": "22.1.5", "@angular/service-worker": "22.1.5", "@capacitor/core": "8.2.0", + "@microsoft/teams-js": "^2.57.0", "@nx/angular": "23.2.0", "@sentry/cli": "^2.28.6", "@types/google.maps": "^3.55.0", From bce37f3641ff62a833f6cfdcdf7b66f35ca44f33 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 18:13:58 +1000 Subject: [PATCH 2/4] feat(outlook-addin): calendar pane for rooms and desks Add a task pane that reads the title, time and All day setting from the open Outlook event instead of asking for them again. - Rooms are added to the event as Exchange room resources, so Exchange books them when the invitation is sent. This replaces the old flow that created a second event. - Desks are reserved on "Add to event" and linked to the saved event in both directions. The pane flags a desk when the event time changes. - Each tab has list and map views. Filters apply in the pane, so a filter change does not start a new availability request. - space-map and desk-map accept an optional filtered list and are exported from their libraries. - The manifest has one PlaceOS button that opens the pane. --- apps/outlook-addin/README.md | 12 + apps/outlook-addin/manifest.xml | 53 +- apps/outlook-addin/src/app/app.component.ts | 13 + apps/outlook-addin/src/app/app.routes.ts | 11 +- .../app/calendar/calendar-pane.component.ts | 125 ++++ .../src/app/calendar/desk-link.service.ts | 168 ++++++ .../src/app/calendar/desk-search.component.ts | 410 +++++++++++++ .../src/app/calendar/outlook-event.service.ts | 168 ++++++ .../src/app/calendar/outlook-event.ts | 163 +++++ .../src/app/calendar/outlook-item.adapter.ts | 264 ++++++++ .../src/app/calendar/room-search.component.ts | 568 ++++++++++++++++++ .../src/app/calendar/view-toggle.component.ts | 42 ++ .../src/tests/outlook-event.service.spec.ts | 79 +++ .../src/tests/outlook-event.spec.ts | 117 ++++ apps/outlook-addin/tsconfig.spec.json | 2 +- libs/bookings/src/index.ts | 1 + .../desk-select-modal/desk-map.component.ts | 15 +- libs/events/src/lib/events.ts | 1 + .../space-select-modal/space-map.component.ts | 14 +- 19 files changed, 2171 insertions(+), 55 deletions(-) create mode 100644 apps/outlook-addin/src/app/calendar/calendar-pane.component.ts create mode 100644 apps/outlook-addin/src/app/calendar/desk-link.service.ts create mode 100644 apps/outlook-addin/src/app/calendar/desk-search.component.ts create mode 100644 apps/outlook-addin/src/app/calendar/outlook-event.service.ts create mode 100644 apps/outlook-addin/src/app/calendar/outlook-event.ts create mode 100644 apps/outlook-addin/src/app/calendar/outlook-item.adapter.ts create mode 100644 apps/outlook-addin/src/app/calendar/room-search.component.ts create mode 100644 apps/outlook-addin/src/app/calendar/view-toggle.component.ts create mode 100644 apps/outlook-addin/src/tests/outlook-event.service.spec.ts create mode 100644 apps/outlook-addin/src/tests/outlook-event.spec.ts diff --git a/apps/outlook-addin/README.md b/apps/outlook-addin/README.md index b612e9c1818..170e7d4531f 100644 --- a/apps/outlook-addin/README.md +++ b/apps/outlook-addin/README.md @@ -9,3 +9,15 @@ - The add-in is currently being side-loaded into the Outlook desktop app. Get Add-in > `My add-ins` tab > `Add a custom add-in` button > `Add from file` > manifest.xml + +## Calendar task pane + +The manifest opens `#/calendar` from the Outlook event organizer form. The pane reads the event title, time and room locations from Outlook. It does not ask for them again. + +- Rooms: "Add to meeting" adds the room to the event as an Exchange room resource. Exchange books the room when the user sends the invitation. +- Desks: "Add to event" reserves the desk at once. It saves a new event first (Outlook does not send invitations for this save). The booking ID is kept in an item custom property. The Outlook item ID and iCalUId are kept in the booking `extension_data`. +- All day: the pane uses the Office.js preview flag when the client supports it. Otherwise it uses the saved event from PlaceOS when the saved times match. It never infers All day from the duration. +- Map: each tab has a List and Map view. The map view uses the shared `space-map` and `desk-map` components. Select a room or desk on the map to show its card and Add button. +- Outside Outlook the pane uses a sample event, so you can use it in the browser with `?mock=true`. + +The code is in `src/app/calendar/`. The manifest requires Mailbox 1.8. diff --git a/apps/outlook-addin/manifest.xml b/apps/outlook-addin/manifest.xml index 7c91e849b0b..f4ad690d376 100644 --- a/apps/outlook-addin/manifest.xml +++ b/apps/outlook-addin/manifest.xml @@ -1,11 +1,11 @@ 28ff1819-77f1-476b-9cff-e59b3fecc655 - 1.0.1.0 + 1.1.0.0 PLACEOS en-US - - + + @@ -36,7 +36,7 @@ false - + @@ -45,7 +45,6 @@ -