mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
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:
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
+1
-1
@@ -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'
|
||||||
|
|||||||
@@ -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.**
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user