@khester/create-dynamics-app 2.6.6 → 2.8.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 (66) hide show
  1. package/README.md +3 -0
  2. package/dist/artifacts/registry.d.ts.map +1 -1
  3. package/dist/artifacts/registry.js +87 -6
  4. package/dist/artifacts/registry.js.map +1 -1
  5. package/dist/artifacts/types.d.ts +1 -1
  6. package/dist/artifacts/types.d.ts.map +1 -1
  7. package/dist/index.js +8 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/scaffold.d.ts +2 -0
  10. package/dist/scaffold.d.ts.map +1 -1
  11. package/dist/scaffold.js +4 -0
  12. package/dist/scaffold.js.map +1 -1
  13. package/dist/utils/copyTemplate.d.ts.map +1 -1
  14. package/dist/utils/copyTemplate.js +26 -7
  15. package/dist/utils/copyTemplate.js.map +1 -1
  16. package/package.json +2 -2
  17. package/templates/teams-app/README.md +32 -9
  18. package/templates/teams-app/{appPackage/manifest.json → _variants/tab.basic/appPackage/manifest.template.json} +6 -5
  19. package/templates/teams-app/_variants/tab.dashboard/appPackage/manifest.template.json +38 -0
  20. package/templates/teams-app/_variants/tab.dashboard/auth.html +15 -0
  21. package/templates/teams-app/_variants/tab.dashboard/env.local.template +3 -0
  22. package/templates/teams-app/_variants/tab.dashboard/src/App.tsx +49 -0
  23. package/templates/teams-app/_variants/tab.dashboard/src/auth-bridge.ts +52 -0
  24. package/templates/teams-app/_variants/tab.dashboard/src/components/Dashboard.tsx +33 -0
  25. package/templates/teams-app/_variants/tab.dashboard/src/components/widgets/StatsWidget.tsx +25 -0
  26. package/templates/teams-app/_variants/tab.dashboard/src/components/widgets/TasksWidget.tsx +20 -0
  27. package/templates/teams-app/_variants/tab.dashboard/src/hooks/useHashRoute.ts +16 -0
  28. package/templates/teams-app/_variants/tab.dashboard/src/hooks/useTabConfig.ts +70 -0
  29. package/templates/teams-app/_variants/tab.dashboard/src/hooks/useTeamsAuth.ts +121 -0
  30. package/templates/teams-app/_variants/tab.dashboard/src/pages/Config.tsx +118 -0
  31. package/templates/teams-app/_variants/tab.dashboard/src/pages/Remove.tsx +12 -0
  32. package/templates/teams-app/_variants/tab.dashboard/src/vite-env.d.ts +10 -0
  33. package/templates/teams-app/_variants/tab.dashboard/vite.config.ts +19 -0
  34. package/templates/teams-app/_variants/tab.sso/appPackage/manifest.template.json +31 -0
  35. package/templates/teams-app/_variants/tab.sso/auth.html +15 -0
  36. package/templates/teams-app/_variants/tab.sso/env.local.template +3 -0
  37. package/templates/teams-app/_variants/tab.sso/src/App.tsx +57 -0
  38. package/templates/teams-app/_variants/tab.sso/src/auth-bridge.ts +52 -0
  39. package/templates/teams-app/_variants/tab.sso/src/hooks/useTeamsAuth.ts +121 -0
  40. package/templates/teams-app/_variants/tab.sso/src/vite-env.d.ts +10 -0
  41. package/templates/teams-app/_variants/tab.sso/vite.config.ts +19 -0
  42. package/templates/teams-app/appPackage/color.png +0 -0
  43. package/templates/teams-app/appPackage/outline.png +0 -0
  44. package/templates/teams-app/gitignore +2 -0
  45. package/templates/teams-app/package.json +1 -1
  46. package/templates/teams-bot/.env.example +18 -0
  47. package/templates/teams-bot/README.md +52 -0
  48. package/templates/teams-bot/_variants/bot.echo/appPackage/manifest.template.json +25 -0
  49. package/templates/teams-bot/_variants/bot.echo/src/bot/TeamsBot.ts +142 -0
  50. package/templates/teams-bot/_variants/bot.echo/src/bot/welcomeCard.ts +134 -0
  51. package/templates/teams-bot/_variants/bot.echo/src/index.ts +59 -0
  52. package/templates/teams-bot/_variants/bot.echo/src/mock/mockMode.ts +104 -0
  53. package/templates/teams-bot/_variants/bot.notification/appPackage/manifest.template.json +25 -0
  54. package/templates/teams-bot/_variants/bot.notification/src/bot/NotificationBot.ts +54 -0
  55. package/templates/teams-bot/_variants/bot.notification/src/index.ts +136 -0
  56. package/templates/teams-bot/_variants/bot.notification/src/mock/mockMode.ts +55 -0
  57. package/templates/teams-bot/_variants/bot.notification/src/storage/conversationStore.ts +84 -0
  58. package/templates/teams-bot/appPackage/color.png +0 -0
  59. package/templates/teams-bot/appPackage/outline.png +0 -0
  60. package/templates/teams-bot/gitignore +8 -0
  61. package/templates/teams-bot/nodemon.json +6 -0
  62. package/templates/teams-bot/package.json +24 -0
  63. package/templates/teams-bot/src/adapters/botAdapter.ts +45 -0
  64. package/templates/teams-bot/src/config.ts +34 -0
  65. package/templates/teams-bot/tsconfig.json +17 -0
  66. /package/templates/teams-app/{src → _variants/tab.basic/src}/App.tsx +0 -0
@@ -0,0 +1,49 @@
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 { useTabConfig } from './hooks/useTabConfig';
13
+ import { Dashboard } from './components/Dashboard';
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
+ export const App: React.FC = () => {
24
+ const route = useHashRoute();
25
+ const { config, loading } = useTabConfig();
26
+ const [theme, setTheme] = React.useState<Theme>(teamsLightTheme);
27
+
28
+ React.useEffect(() => {
29
+ const init = async () => {
30
+ try {
31
+ await app.initialize();
32
+ const context = await app.getContext();
33
+ setTheme(THEMES[context.app.theme] ?? teamsLightTheme);
34
+ app.registerOnThemeChangeHandler((t) => setTheme(THEMES[t] ?? teamsLightTheme));
35
+ } catch {
36
+ // Standalone browser mode — keep the light theme.
37
+ }
38
+ };
39
+ void init();
40
+ }, []);
41
+
42
+ let page: React.ReactNode;
43
+ if (route === '/config') page = <Config />;
44
+ else if (route === '/remove') page = <Remove />;
45
+ else if (loading) page = <Spinner label="Loading dashboard…" />;
46
+ else page = <Dashboard config={config} />;
47
+
48
+ return <FluentProvider theme={theme}>{page}</FluentProvider>;
49
+ };
@@ -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
+ })();
@@ -0,0 +1,33 @@
1
+ import * as React from 'react';
2
+ import { Text } from '@fluentui/react-components';
3
+ import { TasksWidget } from './widgets/TasksWidget';
4
+ import { StatsWidget } from './widgets/StatsWidget';
5
+ import type { TabConfig } from '../hooks/useTabConfig';
6
+
7
+ /** Registry of available widgets — Config.tsx offers exactly these ids. */
8
+ export const WIDGETS: Record<string, { label: string; component: React.FC }> = {
9
+ tasks: { label: 'My Tasks', component: TasksWidget },
10
+ stats: { label: 'At a Glance', component: StatsWidget },
11
+ };
12
+
13
+ export const Dashboard: React.FC<{ config: TabConfig }> = ({ config }) => (
14
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: 16 }}>
15
+ <Text size={600} weight="semibold">
16
+ {config.title}
17
+ </Text>
18
+ <div
19
+ style={{
20
+ display: 'grid',
21
+ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
22
+ gap: 16,
23
+ }}
24
+ >
25
+ {config.widgets.map((id) => {
26
+ const widget = WIDGETS[id];
27
+ if (!widget) return null;
28
+ const Widget = widget.component;
29
+ return <Widget key={id} />;
30
+ })}
31
+ </div>
32
+ </div>
33
+ );
@@ -0,0 +1,25 @@
1
+ import * as React from 'react';
2
+ import { Card, CardHeader, Text } from '@fluentui/react-components';
3
+
4
+ const SAMPLE_STATS = [
5
+ { label: 'Open items', value: 12 },
6
+ { label: 'Due this week', value: 4 },
7
+ { label: 'Completed', value: 27 },
8
+ ];
9
+
10
+ /** Sample stats widget — replace SAMPLE_STATS with a real data source. */
11
+ export const StatsWidget: React.FC = () => (
12
+ <Card>
13
+ <CardHeader header={<Text weight="semibold">At a Glance</Text>} />
14
+ <div style={{ display: 'flex', gap: 24 }}>
15
+ {SAMPLE_STATS.map((stat) => (
16
+ <div key={stat.label} style={{ display: 'flex', flexDirection: 'column' }}>
17
+ <Text size={700} weight="bold">
18
+ {stat.value}
19
+ </Text>
20
+ <Text size={200}>{stat.label}</Text>
21
+ </div>
22
+ ))}
23
+ </div>
24
+ </Card>
25
+ );
@@ -0,0 +1,20 @@
1
+ import * as React from 'react';
2
+ import { Card, CardHeader, Text, Checkbox } from '@fluentui/react-components';
3
+
4
+ const SAMPLE_TASKS = [
5
+ { id: 1, label: 'Review the quarterly report', done: true },
6
+ { id: 2, label: 'Prepare the customer demo', done: false },
7
+ { id: 3, label: 'Follow up with the design team', done: false },
8
+ ];
9
+
10
+ /** Sample task-list widget — replace SAMPLE_TASKS with a real data source. */
11
+ export const TasksWidget: React.FC = () => (
12
+ <Card>
13
+ <CardHeader header={<Text weight="semibold">My Tasks</Text>} />
14
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
15
+ {SAMPLE_TASKS.map((task) => (
16
+ <Checkbox key={task.id} label={task.label} defaultChecked={task.done} />
17
+ ))}
18
+ </div>
19
+ </Card>
20
+ );
@@ -0,0 +1,16 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ /**
4
+ * Tiny hash router — `#/config` → `/config`. Hash routing keeps the tab a single
5
+ * static bundle (no server rewrites needed on Static Web Apps), which is why the
6
+ * manifest's configurationUrl points at `/#/config`.
7
+ */
8
+ export function useHashRoute(): string {
9
+ const [hash, setHash] = useState(window.location.hash);
10
+ useEffect(() => {
11
+ const onChange = () => setHash(window.location.hash);
12
+ window.addEventListener('hashchange', onChange);
13
+ return () => window.removeEventListener('hashchange', onChange);
14
+ }, []);
15
+ return hash.replace(/^#/, '') || '/';
16
+ }
@@ -0,0 +1,70 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { app } from '@microsoft/teams-js';
3
+
4
+ /** Per-tab dashboard settings, keyed by the Teams entityId (one config per pinned tab). */
5
+ export interface TabConfig {
6
+ title: string;
7
+ /** Ids of the widgets to show (see components/widgets). */
8
+ widgets: string[];
9
+ }
10
+
11
+ export const DEFAULT_CONFIG: TabConfig = {
12
+ title: 'Dashboard',
13
+ widgets: ['tasks', 'stats'],
14
+ };
15
+
16
+ const STORAGE_KEY = 'tab-config';
17
+
18
+ /**
19
+ * Load + persist the tab's config. Storage is localStorage keyed by the Teams
20
+ * entityId — swap `loadConfig`/`saveConfig` for a backing service when the
21
+ * dashboard needs org-wide settings.
22
+ */
23
+ export function useTabConfig(): {
24
+ config: TabConfig;
25
+ entityId: string;
26
+ loading: boolean;
27
+ saveConfig: (config: TabConfig, entityIdOverride?: string) => void;
28
+ } {
29
+ const [config, setConfig] = useState<TabConfig>(DEFAULT_CONFIG);
30
+ const [entityId, setEntityId] = useState('default');
31
+ const [loading, setLoading] = useState(true);
32
+
33
+ useEffect(() => {
34
+ const load = async () => {
35
+ let id = 'default';
36
+ try {
37
+ await app.initialize();
38
+ const context = await app.getContext();
39
+ id = context.page?.id || 'default';
40
+ } catch {
41
+ // Outside Teams — allow ?entityId= for local testing.
42
+ id = new URLSearchParams(window.location.search).get('entityId') || 'default';
43
+ }
44
+ setEntityId(id);
45
+ const stored = localStorage.getItem(`${STORAGE_KEY}-${id}`);
46
+ if (stored) {
47
+ try {
48
+ setConfig(JSON.parse(stored) as TabConfig);
49
+ } catch {
50
+ setConfig(DEFAULT_CONFIG);
51
+ }
52
+ }
53
+ setLoading(false);
54
+ };
55
+ void load();
56
+ }, []);
57
+
58
+ const saveConfig = useCallback(
59
+ (next: TabConfig, entityIdOverride?: string) => {
60
+ // The config page mints a fresh entityId for a first-time pin (the Teams
61
+ // context has no page id yet during configuration) — persist under it.
62
+ const id = entityIdOverride ?? entityId;
63
+ localStorage.setItem(`${STORAGE_KEY}-${id}`, JSON.stringify(next));
64
+ setConfig(next);
65
+ },
66
+ [entityId]
67
+ );
68
+
69
+ return { config, entityId, loading, saveConfig };
70
+ }
@@ -0,0 +1,121 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { app, authentication } from '@microsoft/teams-js';
3
+ import { PublicClientApplication, type AccountInfo } from '@azure/msal-browser';
4
+
5
+ /**
6
+ * Teams-first auth: try Teams SSO (`authentication.getAuthToken()`), fall back to
7
+ * MSAL. Inside the Teams iframe, interactive sign-in must go through the auth
8
+ * bridge (`auth.html`) — `loginPopup` is blocked there; outside Teams (plain
9
+ * browser tab) MSAL redirect works directly.
10
+ *
11
+ * VITE_CLIENT_ID / VITE_TENANT_ID come from `.env.local` (see env.local.template);
12
+ * `teams-app-manager.sh identity` writes them after registering the Entra app.
13
+ */
14
+ const clientId = import.meta.env.VITE_CLIENT_ID ?? '';
15
+ const tenantId = import.meta.env.VITE_TENANT_ID ?? 'common';
16
+
17
+ const msalInstance = new PublicClientApplication({
18
+ auth: {
19
+ clientId,
20
+ authority: `https://login.microsoftonline.com/${tenantId}`,
21
+ redirectUri: window.location.origin,
22
+ },
23
+ cache: { cacheLocation: 'localStorage' },
24
+ });
25
+
26
+ export interface AuthState {
27
+ token: string | null;
28
+ account: AccountInfo | null;
29
+ inTeams: boolean;
30
+ loading: boolean;
31
+ error: Error | null;
32
+ }
33
+
34
+ export function useTeamsAuth(): AuthState & { login: () => Promise<void> } {
35
+ const [state, setState] = useState<AuthState>({
36
+ token: null,
37
+ account: null,
38
+ inTeams: false,
39
+ loading: true,
40
+ error: null,
41
+ });
42
+
43
+ const getToken = useCallback(async () => {
44
+ // 1. Teams SSO — works once the app registration exposes api://{host}/{clientId}
45
+ // with the Teams client ids pre-authorized.
46
+ try {
47
+ await app.initialize();
48
+ const ssoToken = await authentication.getAuthToken();
49
+ setState({ token: ssoToken, account: null, inTeams: true, loading: false, error: null });
50
+ return;
51
+ } catch {
52
+ // Not in Teams, or SSO not consented yet — fall through to MSAL.
53
+ }
54
+
55
+ // 2. MSAL silent — a previous interactive sign-in left an account in the cache.
56
+ try {
57
+ await msalInstance.initialize();
58
+ const redirect = await msalInstance.handleRedirectPromise().catch(() => null);
59
+ const account = redirect?.account ?? msalInstance.getAllAccounts()[0];
60
+ if (account) {
61
+ const result = await msalInstance.acquireTokenSilent({
62
+ scopes: ['User.Read'],
63
+ account,
64
+ });
65
+ setState({
66
+ token: result.accessToken,
67
+ account: result.account,
68
+ inTeams: false,
69
+ loading: false,
70
+ error: null,
71
+ });
72
+ return;
73
+ }
74
+ setState((prev) => ({ ...prev, loading: false }));
75
+ } catch (error) {
76
+ setState((prev) => ({ ...prev, loading: false, error: error as Error }));
77
+ }
78
+ }, []);
79
+
80
+ /** Interactive sign-in. In Teams → auth bridge popup; in a browser → MSAL redirect. */
81
+ const login = useCallback(async () => {
82
+ if (!clientId) {
83
+ setState((prev) => ({
84
+ ...prev,
85
+ error: new Error('VITE_CLIENT_ID is not set — copy env.local.template to .env.local'),
86
+ }));
87
+ return;
88
+ }
89
+ let inTeams = false;
90
+ try {
91
+ await app.initialize();
92
+ inTeams = true;
93
+ } catch {
94
+ // Plain browser tab.
95
+ }
96
+
97
+ try {
98
+ if (inTeams) {
99
+ // Teams opens auth.html in a real window where MSAL's redirect flow is
100
+ // allowed; the bridge calls notifySuccess/notifyFailure when done.
101
+ await authentication.authenticate({
102
+ url: `${window.location.origin}/auth.html`,
103
+ width: 600,
104
+ height: 535,
105
+ });
106
+ await getToken();
107
+ return;
108
+ }
109
+ await msalInstance.initialize();
110
+ await msalInstance.loginRedirect({ scopes: ['User.Read'] });
111
+ } catch (error) {
112
+ setState((prev) => ({ ...prev, error: error as Error }));
113
+ }
114
+ }, [getToken]);
115
+
116
+ useEffect(() => {
117
+ void getToken();
118
+ }, [getToken]);
119
+
120
+ return { ...state, login };
121
+ }
@@ -0,0 +1,118 @@
1
+ import * as React from 'react';
2
+ import {
3
+ Text,
4
+ Input,
5
+ Checkbox,
6
+ Button,
7
+ MessageBar,
8
+ MessageBarBody,
9
+ } from '@fluentui/react-components';
10
+ import { app, pages } from '@microsoft/teams-js';
11
+ import { useTabConfig, type TabConfig } from '../hooks/useTabConfig';
12
+ import { WIDGETS } from '../components/Dashboard';
13
+
14
+ /**
15
+ * The channel-tab configuration page (manifest configurableTabs.configurationUrl
16
+ * → /#/config). Inside Teams, saving goes through pages.config.registerOnSaveHandler;
17
+ * outside Teams a plain Save button exercises the same persistence for local testing.
18
+ */
19
+ export const Config: React.FC = () => {
20
+ const { config, entityId, loading, saveConfig } = useTabConfig();
21
+ const [draft, setDraft] = React.useState<TabConfig | null>(null);
22
+ const [inTeams, setInTeams] = React.useState(false);
23
+ const [saved, setSaved] = React.useState(false);
24
+
25
+ const current = draft ?? config;
26
+ const draftRef = React.useRef(current);
27
+ draftRef.current = current;
28
+
29
+ React.useEffect(() => {
30
+ const init = async () => {
31
+ try {
32
+ await app.initialize();
33
+ setInTeams(true);
34
+ pages.config.registerOnSaveHandler((saveEvent) => {
35
+ const next = draftRef.current;
36
+ // During first-time configuration the Teams context has no page id yet,
37
+ // so every tab would collide on 'default' — mint a unique entityId; it
38
+ // comes back as context.page.id when the pinned tab loads.
39
+ const saveEntityId =
40
+ entityId && entityId !== 'default' ? entityId : `dashboard-${Date.now()}`;
41
+ void pages.config
42
+ .setConfig({
43
+ suggestedDisplayName: next.title,
44
+ entityId: saveEntityId,
45
+ contentUrl: `${window.location.origin}/`,
46
+ websiteUrl: `${window.location.origin}/`,
47
+ removeUrl: `${window.location.origin}/#/remove`,
48
+ })
49
+ .then(() => {
50
+ saveConfig(next, saveEntityId);
51
+ saveEvent.notifySuccess();
52
+ })
53
+ .catch(() => saveEvent.notifyFailure('Failed to save tab configuration'));
54
+ });
55
+ void pages.config.setValidityState(true);
56
+ } catch {
57
+ setInTeams(false);
58
+ }
59
+ };
60
+ void init();
61
+ }, [entityId, saveConfig]);
62
+
63
+ if (loading) return null;
64
+
65
+ const toggleWidget = (id: string, checked: boolean) => {
66
+ const widgets = checked
67
+ ? [...current.widgets.filter((w) => w !== id), id]
68
+ : current.widgets.filter((w) => w !== id);
69
+ setDraft({ ...current, widgets });
70
+ };
71
+
72
+ return (
73
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 16, maxWidth: 480 }}>
74
+ <Text size={500} weight="semibold">
75
+ Configure dashboard
76
+ </Text>
77
+ <Input
78
+ value={current.title}
79
+ onChange={(_, data) => setDraft({ ...current, title: data.value })}
80
+ placeholder="Dashboard title"
81
+ aria-label="Dashboard title"
82
+ />
83
+ <Text weight="semibold">Widgets</Text>
84
+ {Object.entries(WIDGETS).map(([id, w]) => (
85
+ <Checkbox
86
+ key={id}
87
+ label={w.label}
88
+ checked={current.widgets.includes(id)}
89
+ onChange={(_, data) => toggleWidget(id, data.checked === true)}
90
+ />
91
+ ))}
92
+ {!inTeams && (
93
+ <>
94
+ <MessageBar intent="info">
95
+ <MessageBarBody>Running outside Teams — Save persists locally.</MessageBarBody>
96
+ </MessageBar>
97
+ <div>
98
+ <Button
99
+ appearance="primary"
100
+ onClick={() => {
101
+ saveConfig(current);
102
+ setSaved(true);
103
+ setTimeout(() => setSaved(false), 3000);
104
+ }}
105
+ >
106
+ Save
107
+ </Button>
108
+ </div>
109
+ {saved && (
110
+ <MessageBar intent="success">
111
+ <MessageBarBody>Saved.</MessageBarBody>
112
+ </MessageBar>
113
+ )}
114
+ </>
115
+ )}
116
+ </div>
117
+ );
118
+ };
@@ -0,0 +1,12 @@
1
+ import * as React from 'react';
2
+ import { Text } from '@fluentui/react-components';
3
+
4
+ /** Shown by Teams when the tab is removed (configurableTabs removeUrl default). */
5
+ export const Remove: React.FC = () => (
6
+ <div style={{ padding: 16 }}>
7
+ <Text>
8
+ Removing this tab deletes its saved dashboard configuration for this channel. The app itself
9
+ stays installed.
10
+ </Text>
11
+ </div>
12
+ );
@@ -0,0 +1,10 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ interface ImportMetaEnv {
4
+ readonly VITE_CLIENT_ID?: string;
5
+ readonly VITE_TENANT_ID?: string;
6
+ }
7
+
8
+ interface ImportMeta {
9
+ readonly env: ImportMetaEnv;
10
+ }
@@ -0,0 +1,19 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import { fileURLToPath, URL } from 'node:url';
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ // Teams tabs must be served over HTTPS; `vite --https` or a tunnel in dev.
8
+ server: { port: 3000 },
9
+ build: {
10
+ outDir: 'dist',
11
+ rollupOptions: {
12
+ // MPA: the tab plus the auth bridge Teams opens for interactive sign-in.
13
+ input: {
14
+ main: fileURLToPath(new URL('./index.html', import.meta.url)),
15
+ auth: fileURLToPath(new URL('./auth.html', import.meta.url)),
16
+ },
17
+ },
18
+ },
19
+ });
@@ -0,0 +1,31 @@
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}} tab", "full": "{{projectName}} Microsoft Teams tab with single sign-on" },
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
+ "permissions": ["identity"],
26
+ "validDomains": ["{{HOSTNAME}}"],
27
+ "webApplicationInfo": {
28
+ "id": "{{CLIENT_ID}}",
29
+ "resource": "api://{{HOSTNAME}}/{{CLIENT_ID}}"
30
+ }
31
+ }
@@ -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,57 @@
1
+ import * as React from 'react';
2
+ import {
3
+ FluentProvider,
4
+ teamsLightTheme,
5
+ Text,
6
+ Spinner,
7
+ Button,
8
+ MessageBar,
9
+ MessageBarBody,
10
+ } from '@fluentui/react-components';
11
+ import { useTeamsAuth } from './hooks/useTeamsAuth';
12
+
13
+ export const App: React.FC = () => {
14
+ const { token, account, inTeams, loading, error, login } = useTeamsAuth();
15
+
16
+ return (
17
+ <FluentProvider theme={teamsLightTheme}>
18
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 16 }}>
19
+ <Text size={600} weight="semibold">{{projectName}}</Text>
20
+ <Text>Microsoft Teams tab with single sign-on — Fluent UI v9.</Text>
21
+
22
+ {loading && <Spinner label="Signing in…" />}
23
+
24
+ {!loading && token && (
25
+ <MessageBar intent="success">
26
+ <MessageBarBody>
27
+ Signed in{account ? ` as ${account.username}` : ' via Teams SSO'}
28
+ {inTeams ? ' (Teams SSO token acquired)' : ' (MSAL fallback)'}
29
+ </MessageBarBody>
30
+ </MessageBar>
31
+ )}
32
+
33
+ {!loading && !token && (
34
+ <>
35
+ <MessageBar intent="info">
36
+ <MessageBarBody>
37
+ Not signed in. Teams SSO needs the Entra app registration in place
38
+ (run <code>teams-app-manager.sh identity</code>); the button below signs in interactively.
39
+ </MessageBarBody>
40
+ </MessageBar>
41
+ <div>
42
+ <Button appearance="primary" onClick={() => void login()}>
43
+ Sign in
44
+ </Button>
45
+ </div>
46
+ </>
47
+ )}
48
+
49
+ {error && (
50
+ <MessageBar intent="error">
51
+ <MessageBarBody>{error.message}</MessageBarBody>
52
+ </MessageBar>
53
+ )}
54
+ </div>
55
+ </FluentProvider>
56
+ );
57
+ };