feat: Add Microsoft Teams as notification provider (issue 260) (#313)

* feat: add Microsoft Teams notification provider

* feat: Add Microsoft Teams as notification provider

* fix: Update database migration

* fix: Remove duplicate file 0058 db migration

* add Microsoft Teams notification provider E2E tests

* fix: externalize e2e testings and profile name trim (#318)

* fix: externalize e2e testings

* fix: externalize e2e tests.

* refactor: improve e2e workflow.

* chore: upgrade workflow action version.

* fix: createEnv by adding emptyStringAsUndefined (#317)

* chore: release 1.18.2

---------

Co-authored-by: killian-larcher <killian.larcher@soluce-technologies.com>
Co-authored-by: Charles GTE <charles.gauthereau@soluce-technologies.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(layout): guard undefined user.name in avatar fallbacks (#320)

* fix(layout): guard undefined user.name in avatar fallbacks

* fix: AUTH_DEFAULT_USER as zod email

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>

* fix: profile name trim

---------

Co-authored-by: killian-larcher <killian.larcher@soluce-technologies.com>
Co-authored-by: Charles GTE <charles.gauthereau@soluce-technologies.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michael Kutý <6du1ro.n@gmail.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>

* chore: release 1.18.3

* chore: Update README.md (#323)

* chore: Update README.md

added unraid link

* chore: Update README.md

fix markdown

* chore: release 1.18.4

* fix: teams.ts with timeout

---------

Co-authored-by: Killian Larcher <98161034+KillianLarcher@users.noreply.github.com>
Co-authored-by: killian-larcher <killian.larcher@soluce-technologies.com>
Co-authored-by: Charles GTE <charles.gauthereau@soluce-technologies.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michael Kutý <6du1ro.n@gmail.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: Shawn M <84647313+hyperion02@users.noreply.github.com>
This commit is contained in:
Malilav
2026-06-19 07:42:50 +02:00
committed by GitHub
co-authored by killian-larcher Charles GTE github-actions[bot] <github-actions[bot]@users.noreply.github.com> github-actions[bot] <github-actions[bot]@users.noreply.github.com> Michael Kutý Killian Larcher github-actions[bot] <github-actions[bot]@users.noreply.github.com> Shawn M
parent 777a8bba3c
commit 130c23dd5b
17 changed files with 3194 additions and 8 deletions
+1 -1
View File
@@ -33,5 +33,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.18.2 version: 1.18.4
date-released: '2026-03-02' date-released: '2026-03-02'
+3 -2
View File
@@ -54,12 +54,13 @@
## Installation ## Installation
You have 4 ways to install Portabase: You have 5 ways to install Portabase:
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli) - Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker) - Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose) - Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
- Kubernetes with Helm [details](https://portabase.io/docs/dashboard/setup#helm) - Unraid Community Apps - [details](https://ca.unraid.net/apps/portabase-dashboard-1sdc97m05ufd7q) - [unraid support thread](https://forums.unraid.net/topic/199072-support-portabase-dashboard-agent-dockers/)
- Kubernetes with Helm - [details](https://portabase.io/docs/dashboard/setup#helm)
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development) - Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
**Ensure Docker is installed on your machine before getting started.** **Ensure Docker is installed on your machine before getting started.**
+1 -1
View File
@@ -45,7 +45,7 @@ export async function remove(page: Page, channelName: string) {
*/ */
export async function create( export async function create(
page: Page, page: Page,
provider: "Discord" | "Gotify" | "ntfy.sh" | "Slack" | "Email" | "Telegram" | "Webhook", provider: "Discord" | "Gotify" | "ntfy.sh" | "Slack" | "Email" | "Telegram" | "Webhook" | "Microsoft Teams",
channelName: string, channelName: string,
fillConfig: (page: Page) => Promise<void>, fillConfig: (page: Page) => Promise<void>,
entrypoint: "auto" | "emptyState" | "button" = "auto", entrypoint: "auto" | "emptyState" | "button" = "auto",
+61
View File
@@ -0,0 +1,61 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "Teams E2E Required";
const invalidChannelName = "Teams E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Teams channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Microsoft Teams", validChannelName, async (page) => {
// await page.getByLabel(/Teams Webhook URL/).fill(getEnv("E2E_NOTIFICATION_TEAMS_WEBHOOK"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Microsoft Teams")).toBeVisible();
// await cancel(page);
// });
//
// test("Edit and test a valid Teams E2E channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Microsoft Teams")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Teams channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Microsoft Teams", invalidChannelName, async (page) => {
await page.getByLabel(/Teams Webhook URL/).fill("https://WRONG_TEAMS_WEBHOOK");
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Teams channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.18.2", "version": "1.18.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_kind" ADD VALUE 'teams';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -435,6 +435,13 @@
"when": 1780822414802, "when": 1780822414802,
"tag": "0061_illegal_mole_man", "tag": "0061_illegal_mole_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 62,
"version": "7",
"when": 1780934519843,
"tag": "0062_futuristic_dragon_lord",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -7,7 +7,7 @@ import {z} from "zod";
import {OrganizationInvitation} from "@/db/schema/05_invitation"; import {OrganizationInvitation} from "@/db/schema/05_invitation";
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud']); export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud', 'teams']);
export const notificationChannel = pgTable('notification_channel', { export const notificationChannel = pgTable('notification_channel', {
id: uuid("id").defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
@@ -10,6 +10,7 @@ import {NextcloudChannelConfigSchema} from "@/features/channel/notifications/nex
import {S3ChannelConfigSchema} from "@/features/channel/storages/s3.schema"; import {S3ChannelConfigSchema} from "@/features/channel/storages/s3.schema";
import {GoogleDriveChannelConfigSchema} from "@/features/channel/storages/google-drive/google-drive.schema"; import {GoogleDriveChannelConfigSchema} from "@/features/channel/storages/google-drive/google-drive.schema";
import {LocalChannelConfigSchema} from "@/features/channel/storages/local.schema"; import {LocalChannelConfigSchema} from "@/features/channel/storages/local.schema";
import {TeamsChannelConfigSchema} from "@/features/channel/notifications/teams.schema";
const BaseChannelFormSchema = z.object({ const BaseChannelFormSchema = z.object({
@@ -53,6 +54,10 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
provider: z.literal("nextcloud"), provider: z.literal("nextcloud"),
config: NextcloudChannelConfigSchema, config: NextcloudChannelConfigSchema,
}), }),
BaseChannelFormSchema.extend({
provider: z.literal("teams"),
config: TeamsChannelConfigSchema,
}),
]); ]);
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [ export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
@@ -26,6 +26,9 @@ import {
import { import {
notificationProviders, notificationProviders,
} from "@/features/channel/channels-notification-helper"; } from "@/features/channel/channels-notification-helper";
import {
NotifierTeamsForm
} from "@/features/channel/notifications/teams.form";
import {storageProviders} from "@/features/channel/channels-storage-helper"; import {storageProviders} from "@/features/channel/channels-storage-helper";
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react"; import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
import {LucideProps} from "lucide-react"; import {LucideProps} from "lucide-react";
@@ -92,6 +95,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
return <NotifierWebhookForm form={form}/>; return <NotifierWebhookForm form={form}/>;
case "nextcloud": case "nextcloud":
return <NotifierNextcloudForm form={form}/>; return <NotifierNextcloudForm form={form}/>;
case "teams":
return <NotifierTeamsForm form={form}/>;
case "s3": case "s3":
return <StorageS3Form form={form}/> return <StorageS3Form form={form}/>
case "google-drive": case "google-drive":
@@ -14,7 +14,7 @@ export const notificationProviders: ProviderIconTypes[] = [
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon}, {value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
{value: "webhook", label: "Webhook", icon: WebhookIcon}, {value: "webhook", label: "Webhook", icon: WebhookIcon},
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon}, {value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true} {value: "teams", label: "Microsoft Teams", icon: MSTeamsIcon}
] ]
@@ -8,6 +8,7 @@ import {sendGotify} from "@/features/channel/notifications/gotify";
import {sendNtfy} from "@/features/channel/notifications/ntfy"; import {sendNtfy} from "@/features/channel/notifications/ntfy";
import {sendWebhook} from "@/features/channel/notifications/webhook"; import {sendWebhook} from "@/features/channel/notifications/webhook";
import {sendNextcloud} from "@/features/channel/notifications/nextcloud"; import {sendNextcloud} from "@/features/channel/notifications/nextcloud";
import {sendTeams} from "@/features/channel/notifications/teams"
const handlers: Record< const handlers: Record<
ProviderKind, ProviderKind,
@@ -21,6 +22,7 @@ const handlers: Record<
ntfy: sendNtfy, ntfy: sendNtfy,
webhook: sendWebhook, webhook: sendWebhook,
nextcloud: sendNextcloud, nextcloud: sendNextcloud,
teams: sendTeams,
}; };
export async function dispatchViaProvider( export async function dispatchViaProvider(
@@ -0,0 +1,38 @@
import { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierTeamsFormProps = {
form: UseFormReturn<any, any, any>;
};
export const NotifierTeamsForm = ({ form }: NotifierTeamsFormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.teamsWebhook"
render={({ field }) => (
<FormItem>
<FormLabel>Teams Webhook URL *</FormLabel>
<FormControl>
<PasswordInput
{...field}
placeholder="e.g. https://xxxxx.logic.azure.com:443/xxxxx"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,5 @@
import {z} from "zod";
export const TeamsChannelConfigSchema = z.object({
teamsWebhook: z.string().url("Must be a valid URL"),
});
@@ -0,0 +1,93 @@
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
export async function sendTeams(
config: { teamsWebhook: string },
payload: EventPayload
): Promise<DispatchResult> {
const { teamsWebhook: webhookUrl } = config;
const levelColor: Record<string, string> = {
critical: 'attention',
warning: 'warning',
info: 'good',
};
const accentColor = levelColor[payload.level] ?? 'default';
const bodyItems: object[] = [
{
type: 'TextBlock',
text: `**[${payload.level.toUpperCase()}] ${payload.title}**`,
wrap: true,
color: accentColor,
size: 'Medium',
weight: 'Bolder',
},
{
type: 'TextBlock',
text: payload.message,
wrap: true,
},
];
if (payload.data) {
bodyItems.push(
{
type: 'TextBlock',
text: '**Data:**',
wrap: true,
weight: 'Bolder',
},
{
type: 'TextBlock',
text: JSON.stringify(payload.data, null, 2).substring(0, 1000),
wrap: true,
fontType: 'Monospace',
}
);
}
const adaptiveCard = {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.2',
body: bodyItems,
};
const body = {
type: 'message',
attachments: [
{
contentType: 'application/vnd.microsoft.card.adaptive',
contentUrl: null,
content: adaptiveCard,
},
],
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
let res: Response;
try {
res = await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
const err = await res.text();
throw new Error(`Teams error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'teams',
message: 'Sent to Microsoft Teams',
response: res.statusText,
};
}
@@ -1,4 +1,4 @@
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook' | 'nextcloud'; export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook' | 'nextcloud' | 'teams';
export interface DispatchResult { export interface DispatchResult {
success: boolean; success: boolean;