@khester/create-dynamics-app 2.9.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +30 -5
  2. package/dist/artifacts/registry.d.ts +7 -5
  3. package/dist/artifacts/registry.d.ts.map +1 -1
  4. package/dist/artifacts/registry.js +44 -18
  5. package/dist/artifacts/registry.js.map +1 -1
  6. package/dist/artifacts/types.d.ts +15 -8
  7. package/dist/artifacts/types.d.ts.map +1 -1
  8. package/dist/index.js +15 -6
  9. package/dist/index.js.map +1 -1
  10. package/dist/scaffold.d.ts.map +1 -1
  11. package/dist/scaffold.js +9 -4
  12. package/dist/scaffold.js.map +1 -1
  13. package/dist/utils/copyTemplate.d.ts +1 -1
  14. package/dist/utils/copyTemplate.js +1 -1
  15. package/package.json +1 -1
  16. package/templates/power-pages-island/README.md +81 -0
  17. package/templates/power-pages-island/package.json +27 -0
  18. package/templates/power-pages-island/public/index.html +13 -0
  19. package/templates/power-pages-island/src/App.tsx +25 -0
  20. package/templates/power-pages-island/src/Component.tsx +89 -0
  21. package/templates/power-pages-island/src/core/services/MockApiService.ts +58 -0
  22. package/templates/power-pages-island/src/core/services/portalSeam.assert.ts +14 -0
  23. package/templates/power-pages-island/src/island-env.d.ts +12 -0
  24. package/templates/power-pages-island/src/islands.tsx +38 -0
  25. package/templates/power-pages-island/tsconfig.json +15 -0
  26. package/templates/power-pages-island/webpack.config.js +22 -0
  27. package/templates/teams-app/README.md +1 -0
  28. package/templates/teams-app/_variants/tab.meeting/appPackage/manifest.template.json +46 -0
  29. package/templates/teams-app/_variants/tab.meeting/auth.html +15 -0
  30. package/templates/teams-app/_variants/tab.meeting/env.local.template +3 -0
  31. package/templates/teams-app/_variants/tab.meeting/src/App.tsx +59 -0
  32. package/templates/teams-app/_variants/tab.meeting/src/auth-bridge.ts +52 -0
  33. package/templates/teams-app/_variants/tab.meeting/src/components/SidePanel.tsx +124 -0
  34. package/templates/teams-app/_variants/tab.meeting/src/components/StageView.tsx +93 -0
  35. package/templates/teams-app/_variants/tab.meeting/src/hooks/useHashRoute.ts +16 -0
  36. package/templates/teams-app/_variants/tab.meeting/src/hooks/useTeamsAuth.ts +121 -0
  37. package/templates/teams-app/_variants/tab.meeting/src/pages/Config.tsx +60 -0
  38. package/templates/teams-app/_variants/tab.meeting/src/pages/Remove.tsx +9 -0
  39. package/templates/teams-app/_variants/tab.meeting/src/vite-env.d.ts +10 -0
  40. package/templates/teams-app/_variants/tab.meeting/vite.config.ts +19 -0
  41. package/templates/power-pages-starter/.env.example +0 -6
  42. package/templates/power-pages-starter/README.md +0 -95
  43. package/templates/power-pages-starter/package.json +0 -43
  44. package/templates/power-pages-starter/public/index.html +0 -15
  45. package/templates/power-pages-starter/src/components/ContactForm.css +0 -84
  46. package/templates/power-pages-starter/src/components/ContactForm.tsx +0 -258
  47. package/templates/power-pages-starter/src/index.tsx +0 -32
  48. package/templates/power-pages-starter/src/providers/PowerPagesProvider.tsx +0 -162
  49. package/templates/power-pages-starter/src/styles/index.css +0 -76
  50. package/templates/power-pages-starter/tsconfig.json +0 -20
  51. package/templates/power-pages-starter/webpack.config.js +0 -57
  52. package/templates/{power-pages-starter → power-pages-island}/gitignore +1 -1
@@ -0,0 +1,81 @@
1
+ # {{projectName}} — Power Pages island
2
+
3
+ A React **island** you mount into an **existing traditional (Liquid) Power Pages portal** — React
4
+ hydrates only the nodes you tag in Liquid, leaving the rest of the server-rendered page untouched.
5
+
6
+ This is the delta Microsoft's Power Pages tooling doesn't cover: the `pac`/plugin **code-site**
7
+ scaffolder builds a whole-page React SPA that *is* the site. It cannot drop a React widget into an
8
+ existing Liquid portal. This template does exactly that.
9
+
10
+ ## Data access — host-portable seam
11
+
12
+ The widget (`src/Component.tsx`) is written against an injected **`IApiService`** (the create-app
13
+ 6-method `dataverse-seam`). In the browser it's given **`PortalSeamService`** from
14
+ [`@dataverse-kit/portal-api-service`](https://www.npmjs.com/package/@dataverse-kit/portal-api-service),
15
+ which talks to the portal Web API (`/_api/`) and handles the anti-forgery token
16
+ (`__RequestVerificationToken`), 403-retry, and idempotency-aware retry for you. On `localhost` it
17
+ falls back to an in-memory `MockApiService`, so it renders with no portal.
18
+
19
+ Because the widget depends only on the seam, **the same `src/Component.tsx` runs unchanged** as a PCF
20
+ control, a Dynamics custom page, or a web resource (`create-dynamics-app … --artifact host-swap`) — the
21
+ only thing that changes is the host wrapper and the injected service.
22
+
23
+ | Path | Role |
24
+ |------|------|
25
+ | `src/islands.tsx` | Entry — mounts React into every `[data-dvkit-island]` node (idempotent) |
26
+ | `src/App.tsx` | Picks `MockApiService` (localhost) or `PortalSeamService` (portal); injects it |
27
+ | `src/Component.tsx` | The host-agnostic widget (an accounts `DetailsList`), depends only on `IApiService` |
28
+ | `src/core/services/*` | The data-access seam — `IApiService` + `Xrm`/`Fetch`/`ServiceFactory` injected from `_shared`; `MockApiService` seed is per-project |
29
+ | `src/core/services/portalSeam.assert.ts` | Compile-time drift guard: `PortalSeamService` stays assignable to the seam |
30
+
31
+ ## Build & preview
32
+
33
+ ```bash
34
+ npm install
35
+ npm run dev # webpack dev server on http://localhost:8080 (renders the island from the mock)
36
+ npm run build # tsc --noEmit + webpack --mode production → dist/{{projectName}}.js
37
+ ```
38
+
39
+ ## Deploy to a traditional portal
40
+
41
+ 1. **Build** → `dist/{{projectName}}.js` (a single, stable-named bundle — no content hash, so the URL
42
+ is fixed).
43
+ 2. **Upload it as a portal Web File** (Power Pages Management app → *Web Files*, or `pac`): set its
44
+ **Parent Page = Home**, give it a partial URL like `{{projectName}}.js`, set the MIME type to
45
+ `text/javascript`, and set **Publishing State = Published**. A web file's URL is its parent page's
46
+ path joined with the partial URL, so parenting to **Home** makes it resolve at the site root:
47
+ `https://<your-portal>/{{projectName}}.js`. (Parent it elsewhere — e.g. under `/documents` — and the
48
+ URL becomes `/documents/{{projectName}}.js`, so the root-relative `<script src>` in step 3 won't find
49
+ it. Either parent to Home, or match the `<script src>` path to the web file's actual URL.)
50
+ 3. **Place the island** on any existing page — paste this into a **Web Template** or **Content Snippet**
51
+ the page renders:
52
+
53
+ ```html
54
+ <div data-dvkit-island="accounts"></div>
55
+ <script src="/{{projectName}}.js"></script>
56
+ ```
57
+
58
+ Add more `<div data-dvkit-island="…">` nodes to mount the widget in several places; the
59
+ `data-dvkit-island` value is a hook for routing multiple widgets later.
60
+
61
+ ## Portal requirements (per table you read/write)
62
+
63
+ The portal Web API is gated per table. For `account` (the sample), configure **Site Settings**:
64
+
65
+ - `Webapi/account/enabled` = `true`
66
+ - `Webapi/account/fields` = the exact columns you read/write (e.g. `name,telephone1` — list the
67
+ `_<lookup>_value` read form too), **mandatory**
68
+ - a **Table Permission** for the signed-in user's Web Role (and, for a `@odata.bind`/associate write,
69
+ **both Append AND AppendTo on both** the source and target permissions)
70
+
71
+ Notes:
72
+ - **Fluent UI v8** — Fluent v9 is broken on Power Pages portals. This template pins v8.
73
+ - **React 18** — pinned (Fluent v8 peers `<19`).
74
+ - Portals expose **no metadata endpoints** — hard-code any option-set labels at dev time.
75
+
76
+ ## Verification status
77
+
78
+ This starter is **build-verified** (`tsc` + webpack bundle) and **localhost-mock-verified**
79
+ (`npm run dev`). Live behavior against a real traditional portal (the web-file upload, the Liquid
80
+ snippet, and `PortalSeamService` against live `/_api/`) is the deploy step above — verify it on your
81
+ own portal.
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "{{projectName}} — React island mounted into an existing traditional Power Pages (Liquid) portal (webpack)",
6
+ "scripts": {
7
+ "dev": "webpack serve --mode development",
8
+ "build": "tsc --noEmit && webpack --mode production",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@dataverse-kit/portal-api-service": "^0.2.0",
13
+ "@fluentui/react": "^8.120.0",
14
+ "react": "^18.2.0",
15
+ "react-dom": "^18.2.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^18.2.0",
19
+ "@types/react-dom": "^18.2.0",
20
+ "html-webpack-plugin": "^5.5.3",
21
+ "ts-loader": "^9.5.1",
22
+ "typescript": "^5.3.3",
23
+ "webpack": "^5.89.0",
24
+ "webpack-cli": "^5.1.4",
25
+ "webpack-dev-server": "^4.15.1"
26
+ }
27
+ }
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{{projectName}} — Power Pages island (dev preview)</title>
7
+ </head>
8
+ <body>
9
+ <!-- On a real portal this node lives in a Liquid web template / content snippet.
10
+ The bundle mounts React into EVERY [data-dvkit-island] node on the page. -->
11
+ <div data-dvkit-island="accounts"></div>
12
+ </body>
13
+ </html>
@@ -0,0 +1,25 @@
1
+ import * as React from "react";
2
+ import type { IApiService } from "./core/services/IApiService";
3
+ import { MockApiService } from "./core/services/MockApiService";
4
+ import { PortalSeamService } from "@dataverse-kit/portal-api-service";
5
+ import { Component } from "./Component";
6
+
7
+ // A Power Pages island is served from the portal origin, so a same-origin `/_api`
8
+ // fetch is authenticated by the portal session cookie + anti-forgery token —
9
+ // PortalSeamService handles both (token fetch/cache, 403-retry, idempotency-aware
10
+ // retry). On localhost (the webpack dev server) fall back to the in-memory mock so
11
+ // it renders with no portal.
12
+ //
13
+ // PortalSeamService implements the create-app 6-method `dataverse-seam`, so it is
14
+ // structurally assignable to IApiService with NO cast — and THIS assignment is the
15
+ // load-bearing compile-time drift guard: if the seam shapes ever diverge, this line
16
+ // fails `tsc`. (See core/services/portalSeam.assert.ts for the explicit fixture.)
17
+ export const App: React.FC<{ island?: string }> = () => {
18
+ // `island` is the `data-dvkit-island` value — a hook for routing multiple widgets
19
+ // on a page. The starter renders the accounts widget for any island.
20
+ const api = React.useMemo<IApiService>(() => {
21
+ const onLocalhost = /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
22
+ return onLocalhost ? new MockApiService() : new PortalSeamService();
23
+ }, []);
24
+ return <Component api={api} />;
25
+ };
@@ -0,0 +1,89 @@
1
+ import * as React from "react";
2
+ import {
3
+ DetailsList,
4
+ SelectionMode,
5
+ MessageBar,
6
+ MessageBarType,
7
+ Spinner,
8
+ Stack,
9
+ Text,
10
+ type IColumn,
11
+ } from "@fluentui/react";
12
+ import { initializeIcons } from "@fluentui/react/lib/Icons";
13
+ import type { IApiService } from "./core/services/IApiService";
14
+
15
+ // Initialize Fluent UI icons once (DetailsList sort/column glyphs).
16
+ initializeIcons();
17
+
18
+ /**
19
+ * The ONE host-agnostic component. It depends on nothing but an injected
20
+ * `IApiService` — no PCF `context`, no Xrm, no lifecycle, no bundler globals — so
21
+ * the SAME source renders unchanged inside a PCF control and a Dynamics custom
22
+ * page. Each host's thin wrapper builds the right `IApiService` and mounts this.
23
+ */
24
+ export interface ComponentProps {
25
+ api: IApiService;
26
+ }
27
+
28
+ const ACCOUNTS_FETCHXML = [
29
+ '<fetch top="25">',
30
+ ' <entity name="account">',
31
+ ' <attribute name="accountid" />',
32
+ ' <attribute name="name" />',
33
+ ' <attribute name="telephone1" />',
34
+ ' <order attribute="name" />',
35
+ " </entity>",
36
+ "</fetch>",
37
+ ].join("\n");
38
+
39
+ interface AccountRow {
40
+ accountid: string;
41
+ name?: string;
42
+ telephone1?: string;
43
+ }
44
+
45
+ const COLUMNS: IColumn[] = [
46
+ { key: "name", name: "Account", fieldName: "name", minWidth: 180, maxWidth: 320, isResizable: true },
47
+ { key: "telephone1", name: "Phone", fieldName: "telephone1", minWidth: 140, isResizable: true },
48
+ ];
49
+
50
+ export const Component: React.FC<ComponentProps> = ({ api }) => {
51
+ const [rows, setRows] = React.useState<AccountRow[] | null>(null);
52
+ const [error, setError] = React.useState<string | null>(null);
53
+
54
+ React.useEffect(() => {
55
+ let cancelled = false;
56
+ api
57
+ .retrieveMultipleRecords("accounts", ACCOUNTS_FETCHXML)
58
+ .then((res) => {
59
+ if (!cancelled) setRows(res.entities as AccountRow[]);
60
+ })
61
+ .catch((e) => {
62
+ if (!cancelled) setError(e instanceof Error ? e.message : String(e));
63
+ });
64
+ return () => {
65
+ cancelled = true;
66
+ };
67
+ }, [api]);
68
+
69
+ return (
70
+ <Stack tokens={{ childrenGap: 12 }} styles={{ root: { padding: 16 } }}>
71
+ <Text variant="xLarge">Accounts</Text>
72
+ <Text variant="small">
73
+ One host-agnostic component — the same source renders in a PCF control and a
74
+ Dynamics custom page; only the thin host wrapper changes.
75
+ </Text>
76
+ {error && <MessageBar messageBarType={MessageBarType.error}>{error}</MessageBar>}
77
+ {!rows && !error && <Spinner label="Loading accounts…" />}
78
+ {rows && (
79
+ <DetailsList
80
+ items={rows}
81
+ columns={COLUMNS}
82
+ setKey="accounts"
83
+ getKey={(item: AccountRow) => item.accountid}
84
+ selectionMode={SelectionMode.none}
85
+ />
86
+ )}
87
+ </Stack>
88
+ );
89
+ };
@@ -0,0 +1,58 @@
1
+ import { IApiService } from "./IApiService";
2
+ import { logCrud } from "./crudLogging";
3
+
4
+ // A few seeded in-memory accounts so the control/page renders with no org or
5
+ // token (the PCF test harness and `npm run dev` both land here). Replace this
6
+ // seed as your component grows. The other IApiService impls (Xrm/Fetch) and the
7
+ // shared contract are pulled from templates/_shared/dataverse-seam at scaffold.
8
+ const SEED_ACCOUNTS = [
9
+ { accountid: "00000000-0000-0000-0000-000000000001", name: "Contoso Ltd", telephone1: "+1 (425) 555-0100" },
10
+ { accountid: "00000000-0000-0000-0000-000000000002", name: "Fabrikam Inc", telephone1: "+1 (206) 555-0140" },
11
+ { accountid: "00000000-0000-0000-0000-000000000003", name: "Adventure Works", telephone1: "+1 (312) 555-0190" },
12
+ ];
13
+
14
+ export class MockApiService implements IApiService {
15
+ private accounts: Record<string, any> = Object.fromEntries(
16
+ SEED_ACCOUNTS.map((a) => [a.accountid, { ...a }]),
17
+ );
18
+ private nextId = SEED_ACCOUNTS.length + 1;
19
+
20
+ retrieveMultipleRecords(entity: string): Promise<{ entities: any[] }> {
21
+ return logCrud(
22
+ { op: "READ", entity, resultCount: (r) => r?.entities?.length },
23
+ async () => ({
24
+ entities: entity === "accounts" ? Object.values(this.accounts) : [],
25
+ }),
26
+ );
27
+ }
28
+
29
+ createRecord(entity: string, record: any): Promise<any> {
30
+ return logCrud({ op: "CREATE", entity, resultId: (r) => r?.id }, async () => {
31
+ const id = `00000000-0000-0000-0000-${String(this.nextId++).padStart(12, "0")}`;
32
+ if (entity === "accounts") this.accounts[id] = { accountid: id, ...record };
33
+ return { id };
34
+ });
35
+ }
36
+
37
+ updateRecord(entity: string, id: string, record: any): Promise<any> {
38
+ return logCrud({ op: "UPDATE", entity, id }, async () => {
39
+ const key = id.replace(/[{}]/g, "");
40
+ if (this.accounts[key]) this.accounts[key] = { ...this.accounts[key], ...record };
41
+ return { success: true };
42
+ });
43
+ }
44
+
45
+ deleteRecord(entity: string, id: string): Promise<void> {
46
+ return logCrud({ op: "DELETE", entity, id }, async () => {
47
+ delete this.accounts[id.replace(/[{}]/g, "")];
48
+ });
49
+ }
50
+
51
+ executeRequest(requestName: string): Promise<any> {
52
+ return logCrud({ op: "EXECUTE", entity: requestName }, async () => ({}));
53
+ }
54
+
55
+ associateRecord(entity: string, id: string): Promise<void> {
56
+ return logCrud({ op: "ASSOCIATE", entity, id }, async () => undefined);
57
+ }
58
+ }
@@ -0,0 +1,14 @@
1
+ // Compile-time drift guard (typechecked by `npm run build` → `tsc --noEmit`, and by
2
+ // the create-dynamics-app power-pages-island template build e2e). `PortalSeamService`
3
+ // — the create-app 6-method `dataverse-seam` adapter shipped in
4
+ // `@dataverse-kit/portal-api-service` — MUST stay structurally assignable to THIS
5
+ // template's local seam `IApiService`, so `App.tsx` can inject it with no cast. That
6
+ // package can't import the create-app template interface, so the assertion lives here,
7
+ // where both types resolve. Type-only — no runtime, tree-shaken from the bundle.
8
+ import type { IApiService } from "./IApiService";
9
+ import type { PortalSeamService } from "@dataverse-kit/portal-api-service";
10
+
11
+ // If PortalSeamService drifts from the seam shape, `true` is no longer assignable to
12
+ // `never` and this fails to compile.
13
+ type PortalSeamSatisfiesSeam = PortalSeamService extends IApiService ? true : never;
14
+ export const _portalSeamDriftGuard: PortalSeamSatisfiesSeam = true;
@@ -0,0 +1,12 @@
1
+ // Webpack/ts-loader builds have no `vite/client` types. The shared data-access seam
2
+ // (templates/_shared/dataverse-seam) includes ServiceFactory, which reads
3
+ // `import.meta.env.VITE_USE_PROXY` for the Vite custom-page host. This Power Pages
4
+ // island does NOT use ServiceFactory (App.tsx builds MockApiService / PortalSeamService
5
+ // directly), so it is tree-shaken from the bundle — but `tsc` / ts-loader still
6
+ // typecheck it. This minimal ambient declaration lets that unused file compile here.
7
+ //
8
+ // NOTE: this file MUST live under src/ — the tsconfig is `include: ["src"]`, so a
9
+ // project-root *.d.ts would be excluded and the shim would be missed.
10
+ interface ImportMeta {
11
+ readonly env: Record<string, unknown>;
12
+ }
@@ -0,0 +1,38 @@
1
+ import * as React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { initLogging } from "@khester/reusable-components";
4
+ import { App } from "./App";
5
+
6
+ // Wire up the localhost-only troubleshooting hooks (window.dumpAppLogs()); no-op in a
7
+ // live portal.
8
+ initLogging();
9
+
10
+ // A traditional Power Pages portal is server-rendered (Liquid). This bundle is an
11
+ // ISLAND: it hydrates React ONLY into the nodes a maker tags in Liquid, leaving the
12
+ // rest of the page untouched — the delta the MS code-site scaffolder (whole-page SPA)
13
+ // can't do. Tag a mount point with `data-dvkit-island="<name>"` (the name is a hook
14
+ // for future per-widget routing) and reference this web file:
15
+ //
16
+ // <div data-dvkit-island="accounts"></div>
17
+ // <script src="/{{projectName}}.js"></script>
18
+ //
19
+ const SELECTOR = "[data-dvkit-island]";
20
+
21
+ function mountIslands(): void {
22
+ document.querySelectorAll<HTMLElement>(SELECTOR).forEach((node) => {
23
+ // Idempotent — a partial-page refresh or a duplicated include must not double-mount.
24
+ if (node.dataset.dvkitMounted === "true") return;
25
+ node.dataset.dvkitMounted = "true";
26
+ createRoot(node).render(
27
+ <React.StrictMode>
28
+ <App island={node.dataset.dvkitIsland} />
29
+ </React.StrictMode>,
30
+ );
31
+ });
32
+ }
33
+
34
+ if (document.readyState === "loading") {
35
+ document.addEventListener("DOMContentLoaded", mountIslands);
36
+ } else {
37
+ mountIslands();
38
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "node",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["DOM", "DOM.Iterable", "ES2020"],
12
+ "types": []
13
+ },
14
+ "include": ["src"]
15
+ }
@@ -0,0 +1,22 @@
1
+ const path = require('path');
2
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
3
+
4
+ // A Power Pages ISLAND: one self-contained bundle, uploaded as a portal WEB FILE and
5
+ // referenced from a Liquid page (see README). The output filename is STABLE (no
6
+ // contenthash) so the Liquid `<script src>` URL stays fixed across rebuilds.
7
+ // HtmlWebpackPlugin exists only for the local `webpack serve` preview — on a real
8
+ // portal the Liquid page is the host, so the emitted index.html is never uploaded.
9
+ module.exports = {
10
+ entry: './src/islands.tsx',
11
+ output: {
12
+ path: path.resolve(__dirname, 'dist'),
13
+ filename: '{{projectName}}.js',
14
+ clean: true,
15
+ },
16
+ resolve: { extensions: ['.ts', '.tsx', '.js'] },
17
+ module: {
18
+ rules: [{ test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ }],
19
+ },
20
+ plugins: [new HtmlWebpackPlugin({ template: './public/index.html' })],
21
+ devServer: { static: './dist', port: 8080, hot: true },
22
+ };
@@ -8,6 +8,7 @@ A **Microsoft Teams tab** — React + Fluent UI v9 + `@microsoft/teams-js`, scaf
8
8
  | `basic` | Personal tab showing the Teams context — no auth. |
9
9
  | `sso` | Teams single sign-on (`getAuthToken`) with an MSAL browser fallback + `auth.html` bridge for interactive sign-in inside Teams. |
10
10
  | `dashboard` | Configurable channel/personal dashboard: widget grid, `/#/config` page wired to `pages.config`, theme-aware. SSO-capable (ships `useTeamsAuth`). |
11
+ | `meeting` | In-meeting app: side panel + shared stage, chosen by the Teams `frameContext`, with `shareAppContentToStage`. Configurable meeting tab (`/#/config`), theme-aware. SSO-capable (ships `useTeamsAuth`). Requires the `MeetingStage.Write.Chat` RSC permission (in the manifest). |
11
12
 
12
13
  ## Develop
13
14
 
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.19/MicrosoftTeams.schema.json",
3
+ "manifestVersion": "1.19",
4
+ "id": "{{TEAMS_APP_ID}}",
5
+ "version": "1.0.0",
6
+ "developer": {
7
+ "name": "Contoso",
8
+ "websiteUrl": "https://example.com",
9
+ "privacyUrl": "https://example.com/privacy",
10
+ "termsOfUseUrl": "https://example.com/terms"
11
+ },
12
+ "name": { "short": "{{projectName}}", "full": "{{projectName}}" },
13
+ "description": { "short": "{{projectName}} meeting app", "full": "{{projectName}} in-meeting Microsoft Teams tab — side panel and shared stage" },
14
+ "icons": { "color": "color.png", "outline": "outline.png" },
15
+ "accentColor": "#6264A7",
16
+ "staticTabs": [
17
+ {
18
+ "entityId": "home",
19
+ "name": "{{projectName}}",
20
+ "contentUrl": "https://{{HOSTNAME}}/",
21
+ "websiteUrl": "https://{{HOSTNAME}}/",
22
+ "scopes": ["personal"]
23
+ }
24
+ ],
25
+ "configurableTabs": [
26
+ {
27
+ "configurationUrl": "https://{{HOSTNAME}}/#/config",
28
+ "canUpdateConfiguration": true,
29
+ "scopes": ["groupChat"],
30
+ "context": ["meetingSidePanel", "meetingStage"]
31
+ }
32
+ ],
33
+ "permissions": ["identity"],
34
+ "validDomains": ["{{HOSTNAME}}"],
35
+ "webApplicationInfo": {
36
+ "id": "{{CLIENT_ID}}",
37
+ "resource": "api://{{HOSTNAME}}/{{CLIENT_ID}}"
38
+ },
39
+ "authorization": {
40
+ "permissions": {
41
+ "resourceSpecific": [
42
+ { "name": "MeetingStage.Write.Chat", "type": "Delegated" }
43
+ ]
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{{projectName}} — Sign in</title>
7
+ <style>
8
+ body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; padding: 24px; color: #555; }
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <p id="status">Working…</p>
13
+ <script type="module" src="/src/auth-bridge.ts"></script>
14
+ </body>
15
+ </html>
@@ -0,0 +1,3 @@
1
+ # Copy to .env.local — `teams-app-manager.sh identity` fills these automatically.
2
+ VITE_CLIENT_ID=
3
+ VITE_TENANT_ID=
@@ -0,0 +1,59 @@
1
+ import * as React from 'react';
2
+ import {
3
+ FluentProvider,
4
+ teamsLightTheme,
5
+ teamsDarkTheme,
6
+ teamsHighContrastTheme,
7
+ type Theme,
8
+ Spinner,
9
+ } from '@fluentui/react-components';
10
+ import { app } from '@microsoft/teams-js';
11
+ import { useHashRoute } from './hooks/useHashRoute';
12
+ import { SidePanel } from './components/SidePanel';
13
+ import { StageView } from './components/StageView';
14
+ import { Config } from './pages/Config';
15
+ import { Remove } from './pages/Remove';
16
+
17
+ const THEMES: Record<string, Theme> = {
18
+ default: teamsLightTheme,
19
+ dark: teamsDarkTheme,
20
+ contrast: teamsHighContrastTheme,
21
+ };
22
+
23
+ /**
24
+ * A Teams meeting app: one SPA that renders the in-meeting side panel or the
25
+ * shared stage depending on the frameContext Teams reports. Route precedence is
26
+ * load-bearing — the tab-configuration page loads at `/#/config` with
27
+ * frameContext `settings`, so `/config` and `/remove` must be matched BEFORE the
28
+ * meetingStage/sidePanel switch, or the config dialog would render the SidePanel.
29
+ */
30
+ export const App: React.FC = () => {
31
+ const route = useHashRoute();
32
+ const [theme, setTheme] = React.useState<Theme>(teamsLightTheme);
33
+ const [frameContext, setFrameContext] = React.useState<string>('sidePanel');
34
+ const [initialized, setInitialized] = React.useState(false);
35
+
36
+ React.useEffect(() => {
37
+ const init = async () => {
38
+ try {
39
+ await app.initialize();
40
+ const context = await app.getContext();
41
+ setTheme(THEMES[context.app.theme] ?? teamsLightTheme);
42
+ setFrameContext(context.page?.frameContext ?? 'sidePanel');
43
+ app.registerOnThemeChangeHandler((t) => setTheme(THEMES[t] ?? teamsLightTheme));
44
+ } catch {
45
+ // Standalone browser mode — keep the light theme and the sidePanel view.
46
+ }
47
+ setInitialized(true);
48
+ };
49
+ void init();
50
+ }, []);
51
+
52
+ let page: React.ReactNode;
53
+ if (route === '/config') page = <Config />;
54
+ else if (route === '/remove') page = <Remove />;
55
+ else if (!initialized) page = <Spinner label="Loading…" />;
56
+ else page = frameContext === 'meetingStage' ? <StageView /> : <SidePanel />;
57
+
58
+ return <FluentProvider theme={theme}>{page}</FluentProvider>;
59
+ };
@@ -0,0 +1,52 @@
1
+ import { app, authentication } from '@microsoft/teams-js';
2
+ import { PublicClientApplication } from '@azure/msal-browser';
3
+
4
+ /**
5
+ * Auth bridge — loaded by auth.html inside the window Teams opens for
6
+ * `authentication.authenticate()`. MSAL's popup flow is blocked inside the Teams
7
+ * tab iframe, so interactive sign-in happens here: loginRedirect → back to this
8
+ * page → handleRedirectPromise → notifySuccess back to the tab.
9
+ */
10
+ const setStatus = (msg: string): void => {
11
+ const el = document.getElementById('status');
12
+ if (el) el.textContent = msg;
13
+ };
14
+
15
+ (async () => {
16
+ try {
17
+ await app.initialize();
18
+
19
+ const clientId = import.meta.env.VITE_CLIENT_ID ?? '';
20
+ const tenantId = import.meta.env.VITE_TENANT_ID ?? 'common';
21
+ if (!clientId) throw new Error('VITE_CLIENT_ID is not set — copy env.local.template to .env.local');
22
+
23
+ const pca = new PublicClientApplication({
24
+ auth: {
25
+ clientId,
26
+ authority: `https://login.microsoftonline.com/${tenantId}`,
27
+ redirectUri: `${window.location.origin}/auth.html`,
28
+ },
29
+ cache: { cacheLocation: 'localStorage' },
30
+ });
31
+ await pca.initialize();
32
+
33
+ setStatus('Completing sign-in…');
34
+ const response = await pca.handleRedirectPromise();
35
+ if (response?.account) {
36
+ setStatus(`Signed in as ${response.account.username}`);
37
+ authentication.notifySuccess(response.account.username);
38
+ return;
39
+ }
40
+
41
+ setStatus('Redirecting to sign in…');
42
+ await pca.loginRedirect({ scopes: ['User.Read'] });
43
+ } catch (err) {
44
+ const msg = err instanceof Error ? err.message : String(err);
45
+ setStatus(`Sign-in failed: ${msg}`);
46
+ try {
47
+ authentication.notifyFailure(msg);
48
+ } catch {
49
+ // Outside a Teams auth window — the status line above is the surface.
50
+ }
51
+ }
52
+ })();