Initial commit: OL Rescue Outlook add-in (New Outlook + Graph)
Made-with: Cursor
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Azure AD app registration (single-page application). See README.
|
||||||
|
VITE_AZURE_CLIENT_ID=
|
||||||
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment — never commit secrets; template is committed
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Test / coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
.cache/
|
||||||
|
.vite/
|
||||||
|
.parcel-cache/
|
||||||
|
.eslintcache
|
||||||
|
*.cache
|
||||||
|
|
||||||
|
# IDE / editors (keep team-shared .vscode/*.json in repo if you add them with !)
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Optional local HTTPS / signing material (never commit real certs)
|
||||||
|
*.pem
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
|
||||||
|
# Archives from npm pack / releases
|
||||||
|
*.tgz
|
||||||
62
README.md
Normal file
62
README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# OL Rescue
|
||||||
|
|
||||||
|
Outlook **Office Web Add-in** that recreates the old “OL Helper” workflow for **New Outlook** (and Outlook on the web): you define a **Tracking ID** and an **Inbox subfolder name**, then the add-in uses **Microsoft Graph** to create the folder (if needed) and move messages that contain `TrackingID#…` in the subject or preview.
|
||||||
|
|
||||||
|
Classic COM/VSTO add-ins do not run in New Outlook. The supported path is **Office.js + Graph**, which this project uses.
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
|
||||||
|
- **Node.js 20+** and npm (for local HTTPS dev and builds).
|
||||||
|
- **Microsoft 365 / Outlook** with add-ins allowed by your org.
|
||||||
|
- An **Azure AD app registration** (see below) with Graph delegated permissions: **Mail.ReadWrite**, **User.Read**.
|
||||||
|
|
||||||
|
## Azure AD app (summary)
|
||||||
|
|
||||||
|
1. In [Azure Portal](https://portal.azure.com) → **Microsoft Entra ID** → **App registrations** → **New registration**.
|
||||||
|
2. Name it (e.g. “OL Rescue”), supported account types per your tenant, **Redirect URI**: platform **Single-page application (SPA)**, URI `https://localhost:3000` (and later your production URL).
|
||||||
|
3. **API permissions** → **Microsoft Graph** → **Delegated**: add **Mail.ReadWrite**, **User.Read** → **Grant admin consent** if required.
|
||||||
|
4. **Overview** → copy **Application (client) ID**.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "/path/to/OL Rescue!"
|
||||||
|
cp .env.example .env
|
||||||
|
# Set VITE_AZURE_CLIENT_ID to your client ID
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite serves **HTTPS** on port **3000** (self-signed cert; your browser will warn once).
|
||||||
|
|
||||||
|
## Sideload the manifest in Outlook (Windows New Outlook)
|
||||||
|
|
||||||
|
1. Ensure `manifest.xml` still points to `https://localhost:3000/index.html` (default for dev).
|
||||||
|
2. In Outlook: **File** → **Manage Add-ins** / **Get Add-ins** → **My add-ins** → **Add a custom add-in** → **Add from file…** → choose `manifest.xml`.
|
||||||
|
|
||||||
|
If your org blocks custom add-ins, an admin may need to deploy via **Centralized Deployment** or allow sideloading.
|
||||||
|
|
||||||
|
## Use the add-in
|
||||||
|
|
||||||
|
1. Open a ticket email and open the **OL Rescue** task pane.
|
||||||
|
2. **Sign in with Microsoft** (Graph consent).
|
||||||
|
3. **Fill from open message** to parse `TrackingID#…` and suggest a folder name, or type **Tracking ID** and **Inbox folder name** manually.
|
||||||
|
4. **Save case**, then **Create folder & move mail** on that case to create `Inbox\<folder name>` and move matching messages.
|
||||||
|
|
||||||
|
Saved cases are stored in **browser localStorage** for this add-in origin.
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
1. Host the `dist/` folder (or your static host) over **HTTPS**.
|
||||||
|
2. Update **Application ID URI / redirect URIs** in Azure for your real origin.
|
||||||
|
3. Edit **manifest.xml** `SourceLocation`, `IconUrl`, and `SupportUrl` to your HTTPS URLs, then rebuild (`npm run build`) and redeploy.
|
||||||
|
|
||||||
|
## Limits vs old OL Helper
|
||||||
|
|
||||||
|
- **Not automatic on every new message** unless you add something else (e.g. Outlook rules calling a service, or Power Automate). This tool is **on-demand** from the task pane.
|
||||||
|
- **Graph search** uses `$filter` on subject/body preview; very unusual formats might need a small follow-up change.
|
||||||
|
- **Shared mailboxes** need extra Graph permissions and possibly UX changes; this MVP targets your primary mailbox (`/me/…`).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Use and modify for your work as needed.
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>OL Rescue</title>
|
||||||
|
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
manifest.xml
Normal file
39
manifest.xml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:type="MailApp">
|
||||||
|
<Id>0b5e2d6a-8a1c-4f2e-9e0b-4a1c2d3e4f5a</Id>
|
||||||
|
<Version>1.0.0.0</Version>
|
||||||
|
<ProviderName>OL Rescue</ProviderName>
|
||||||
|
<DefaultLocale>en-US</DefaultLocale>
|
||||||
|
<DisplayName DefaultValue="OL Rescue"/>
|
||||||
|
<Description DefaultValue="File mail by TrackingID into Inbox subfolders for New Outlook (Microsoft Graph)."/>
|
||||||
|
<IconUrl DefaultValue="https://localhost:3000/icon-32.png"/>
|
||||||
|
<HighResolutionIconUrl DefaultValue="https://localhost:3000/icon-128.png"/>
|
||||||
|
<SupportUrl DefaultValue="https://localhost:3000"/>
|
||||||
|
<AppDomains>
|
||||||
|
<AppDomain>https://localhost:3000</AppDomain>
|
||||||
|
<AppDomain>https://login.microsoftonline.com</AppDomain>
|
||||||
|
</AppDomains>
|
||||||
|
<Hosts>
|
||||||
|
<Host Name="Mailbox"/>
|
||||||
|
</Hosts>
|
||||||
|
<Requirements>
|
||||||
|
<Sets>
|
||||||
|
<Set Name="Mailbox" MinVersion="1.8"/>
|
||||||
|
</Sets>
|
||||||
|
</Requirements>
|
||||||
|
<FormSettings>
|
||||||
|
<Form xsi:type="ItemRead">
|
||||||
|
<DesktopSettings>
|
||||||
|
<SourceLocation DefaultValue="https://localhost:3000/index.html"/>
|
||||||
|
<RequestedHeight>450</RequestedHeight>
|
||||||
|
</DesktopSettings>
|
||||||
|
</Form>
|
||||||
|
</FormSettings>
|
||||||
|
<Permissions>ReadWriteItem</Permissions>
|
||||||
|
<Rule xsi:type="Rule" FormType="Read">
|
||||||
|
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
|
||||||
|
</Rule>
|
||||||
|
<DisableEntityHighlighting>false</DisableEntityHighlighting>
|
||||||
|
</OfficeApp>
|
||||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "ol-rescue",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Outlook add-in: file mail by internal TrackingID into named folders (New Outlook / web)",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/msal-browser": "^4.27.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/office-js": "^1.0.534",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^1.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/icon-128.png
Normal file
BIN
public/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 306 B |
BIN
public/icon-32.png
Normal file
BIN
public/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 B |
329
src/app.ts
Normal file
329
src/app.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import {
|
||||||
|
createMsal,
|
||||||
|
ensureActiveAccount,
|
||||||
|
getActiveAccount,
|
||||||
|
getGraphAccessToken,
|
||||||
|
initializeAuth,
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
} from "./auth";
|
||||||
|
import {
|
||||||
|
findMessagesWithTrackingId,
|
||||||
|
findOrCreateInboxChildFolder,
|
||||||
|
moveMessageToFolder,
|
||||||
|
} from "./graph";
|
||||||
|
import { extractTrackingId, suggestFolderDisplayName } from "./parse";
|
||||||
|
import { type CaseEntry, loadCases, saveCases } from "./storage";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
function el<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
tag: K,
|
||||||
|
props: Record<string, string | undefined> = {},
|
||||||
|
children: (Node | string)[] = [],
|
||||||
|
): HTMLElement {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(props)) {
|
||||||
|
if (v === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (k === "className") {
|
||||||
|
node.className = v;
|
||||||
|
} else if (k.startsWith("on") && typeof v === "string") {
|
||||||
|
// not used
|
||||||
|
} else {
|
||||||
|
node.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const c of children) {
|
||||||
|
node.append(typeof c === "string" ? document.createTextNode(c) : c);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientId(): string | undefined {
|
||||||
|
const id = import.meta.env.VITE_AZURE_CLIENT_ID?.trim();
|
||||||
|
return id || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initApp(): Promise<void> {
|
||||||
|
const root = document.getElementById("app");
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = getClientId();
|
||||||
|
if (!clientId) {
|
||||||
|
root.replaceChildren(
|
||||||
|
el("div", { className: "panel" }, [
|
||||||
|
el("h1", {}, ["OL Rescue"]),
|
||||||
|
el("p", {}, [
|
||||||
|
"Add your Azure AD app client ID to ",
|
||||||
|
el("code", {}, [".env"]),
|
||||||
|
" as ",
|
||||||
|
el("code", {}, ["VITE_AZURE_CLIENT_ID"]),
|
||||||
|
", then restart ",
|
||||||
|
el("code", {}, ["npm run dev"]),
|
||||||
|
".",
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msal = createMsal(clientId);
|
||||||
|
await initializeAuth(msal);
|
||||||
|
ensureActiveAccount(msal);
|
||||||
|
|
||||||
|
let cases = loadCases();
|
||||||
|
|
||||||
|
const statusEl = el("div", { className: "status", role: "status" });
|
||||||
|
const logEl = el("pre", { className: "log" });
|
||||||
|
|
||||||
|
function setStatus(msg: string): void {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLog(line: string): void {
|
||||||
|
logEl.textContent = `${logEl.textContent ?? ""}${line}\n`;
|
||||||
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackingInput = el("input", {
|
||||||
|
type: "text",
|
||||||
|
className: "input",
|
||||||
|
placeholder: "e.g. 2603260030007249",
|
||||||
|
"aria-label": "Tracking ID",
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
|
||||||
|
const folderInput = el("input", {
|
||||||
|
type: "text",
|
||||||
|
className: "input",
|
||||||
|
placeholder: 'e.g. 2603260030007249 Crazy Netflix Case',
|
||||||
|
"aria-label": "Folder name under Inbox",
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
|
||||||
|
const caseList = el("div", { className: "case-list" });
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
saveCases(cases);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCaseList(): void {
|
||||||
|
caseList.replaceChildren();
|
||||||
|
if (cases.length === 0) {
|
||||||
|
caseList.append(el("p", { className: "muted" }, ["No saved cases yet."]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const c of cases) {
|
||||||
|
const row = el("div", { className: "case-row" });
|
||||||
|
row.append(
|
||||||
|
el("div", { className: "case-meta" }, [
|
||||||
|
el("strong", {}, [c.folderDisplayName]),
|
||||||
|
el("span", { className: "muted" }, [` · TrackingID#${c.trackingId}`]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const actions = el("div", { className: "case-actions" });
|
||||||
|
const syncBtn = el("button", { type: "button", className: "btn btn-small" }, [
|
||||||
|
"Create folder & move mail",
|
||||||
|
]);
|
||||||
|
syncBtn.addEventListener("click", () => void syncCase(c.id));
|
||||||
|
const removeBtn = el("button", { type: "button", className: "btn btn-small btn-ghost" }, [
|
||||||
|
"Remove",
|
||||||
|
]);
|
||||||
|
removeBtn.addEventListener("click", () => {
|
||||||
|
cases = cases.filter((x) => x.id !== c.id);
|
||||||
|
persist();
|
||||||
|
renderCaseList();
|
||||||
|
});
|
||||||
|
actions.append(syncBtn, removeBtn);
|
||||||
|
row.append(actions);
|
||||||
|
caseList.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncCase(caseId: string): Promise<void> {
|
||||||
|
const entry = cases.find((c) => c.id === caseId);
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("Working…");
|
||||||
|
appendLog(`— ${entry.folderDisplayName}`);
|
||||||
|
try {
|
||||||
|
const token = await getGraphAccessToken(msal);
|
||||||
|
appendLog("Ensuring folder exists under Inbox…");
|
||||||
|
const folder = await findOrCreateInboxChildFolder(token, entry.folderDisplayName);
|
||||||
|
entry.graphFolderId = folder.id;
|
||||||
|
persist();
|
||||||
|
appendLog(`Finding messages with TrackingID#${entry.trackingId}…`);
|
||||||
|
const messages = await findMessagesWithTrackingId(token, entry.trackingId);
|
||||||
|
appendLog(`Found ${messages.length} message(s).`);
|
||||||
|
let moved = 0;
|
||||||
|
for (const m of messages) {
|
||||||
|
try {
|
||||||
|
await moveMessageToFolder(token, m.id, folder.id);
|
||||||
|
moved += 1;
|
||||||
|
appendLog(`Moved: ${m.subject ?? m.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
appendLog(`Skip/fail: ${m.subject ?? m.id} — ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendLog(`Done. Moved ${moved} message(s).`);
|
||||||
|
setStatus(`Last run: moved ${moved} of ${messages.length}.`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
appendLog(`Error: ${msg}`);
|
||||||
|
setStatus("Failed — see log.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillFromOpenMessage(): Promise<void> {
|
||||||
|
const item = Office.context.mailbox.item;
|
||||||
|
if (!item) {
|
||||||
|
setStatus("Open a message first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("Reading open message…");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
item.subject.getAsync((r) => {
|
||||||
|
if (r.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
|
reject(new Error(r.error?.message ?? "Could not read subject."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const subject = r.value ?? "";
|
||||||
|
const id = extractTrackingId(subject);
|
||||||
|
if (id) {
|
||||||
|
trackingInput.value = id;
|
||||||
|
folderInput.value = suggestFolderDisplayName(subject, id);
|
||||||
|
setStatus("Filled from subject.");
|
||||||
|
} else {
|
||||||
|
item.body.getAsync(Office.CoercionType.Text, (br) => {
|
||||||
|
if (br.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
|
reject(new Error(br.error?.message ?? "Could not read body."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bodyText = br.value ?? "";
|
||||||
|
const id2 = extractTrackingId(bodyText);
|
||||||
|
if (id2) {
|
||||||
|
trackingInput.value = id2;
|
||||||
|
folderInput.value = suggestFolderDisplayName(subject || "Case", id2);
|
||||||
|
setStatus("Filled from body text.");
|
||||||
|
} else {
|
||||||
|
setStatus("No TrackingID# found in subject or body.");
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}).catch((e) => {
|
||||||
|
setStatus(e instanceof Error ? e.message : String(e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOrUpdateCase(): void {
|
||||||
|
const trackingId = trackingInput.value.trim();
|
||||||
|
const folderDisplayName = folderInput.value.trim();
|
||||||
|
if (!/^\d+$/.test(trackingId)) {
|
||||||
|
setStatus("Tracking ID should be digits only (from TrackingID#…).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!folderDisplayName) {
|
||||||
|
setStatus("Enter a folder name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = cases.find((c) => c.trackingId === trackingId);
|
||||||
|
if (existing) {
|
||||||
|
existing.folderDisplayName = folderDisplayName;
|
||||||
|
} else {
|
||||||
|
cases.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
trackingId,
|
||||||
|
folderDisplayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
renderCaseList();
|
||||||
|
setStatus("Case saved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const authBar = el("div", { className: "auth-bar" });
|
||||||
|
const userLabel = el("span", { className: "muted" });
|
||||||
|
|
||||||
|
function renderAuth(): void {
|
||||||
|
authBar.replaceChildren();
|
||||||
|
const acc = getActiveAccount(msal);
|
||||||
|
if (acc) {
|
||||||
|
userLabel.textContent = acc.username ?? acc.name ?? "Signed in";
|
||||||
|
const outBtn = el("button", { type: "button", className: "btn btn-ghost" }, ["Sign out"]);
|
||||||
|
outBtn.addEventListener("click", () => {
|
||||||
|
signOut(msal);
|
||||||
|
userLabel.textContent = "";
|
||||||
|
renderAuth();
|
||||||
|
setStatus("Signed out.");
|
||||||
|
});
|
||||||
|
authBar.append(userLabel, outBtn);
|
||||||
|
} else {
|
||||||
|
const inBtn = el("button", { type: "button", className: "btn" }, ["Sign in with Microsoft"]);
|
||||||
|
inBtn.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await signIn(msal);
|
||||||
|
renderAuth();
|
||||||
|
setStatus("Signed in.");
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
authBar.append(inBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAuth();
|
||||||
|
renderCaseList();
|
||||||
|
|
||||||
|
const saveCaseBtn = el("button", { type: "button", className: "btn" }, ["Save case"]);
|
||||||
|
const fillFromBtn = el("button", { type: "button", className: "btn btn-secondary" }, [
|
||||||
|
"Fill from open message",
|
||||||
|
]);
|
||||||
|
saveCaseBtn.addEventListener("click", () => addOrUpdateCase());
|
||||||
|
fillFromBtn.addEventListener("click", () => void fillFromOpenMessage());
|
||||||
|
|
||||||
|
root.replaceChildren(
|
||||||
|
el("div", { className: "wrap" }, [
|
||||||
|
el("header", { className: "header" }, [
|
||||||
|
el("h1", {}, ["OL Rescue"]),
|
||||||
|
el("p", { className: "lede" }, [
|
||||||
|
"Save a case (Tracking ID + folder name), then move matching mail into an Inbox subfolder. Works in ",
|
||||||
|
el("strong", {}, ["New Outlook"]),
|
||||||
|
" via Microsoft Graph.",
|
||||||
|
]),
|
||||||
|
authBar,
|
||||||
|
]),
|
||||||
|
el("section", { className: "panel" }, [
|
||||||
|
el("h2", {}, ["Case"]),
|
||||||
|
el("label", { className: "label" }, ["Tracking ID"]),
|
||||||
|
trackingInput,
|
||||||
|
el("label", { className: "label" }, ["Inbox folder name"]),
|
||||||
|
folderInput,
|
||||||
|
el("div", { className: "btn-row" }, [saveCaseBtn, fillFromBtn]),
|
||||||
|
]),
|
||||||
|
el("section", { className: "panel" }, [
|
||||||
|
el("h2", {}, ["Saved cases"]),
|
||||||
|
caseList,
|
||||||
|
]),
|
||||||
|
el("section", { className: "panel" }, [
|
||||||
|
el("h2", {}, ["Activity"]),
|
||||||
|
statusEl,
|
||||||
|
logEl,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (getActiveAccount(msal)) {
|
||||||
|
setStatus("Ready.");
|
||||||
|
} else {
|
||||||
|
setStatus("Sign in to use Microsoft Graph.");
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/auth.ts
Normal file
80
src/auth.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
InteractionRequiredAuthError,
|
||||||
|
type AccountInfo,
|
||||||
|
type IPublicClientApplication,
|
||||||
|
PublicClientApplication,
|
||||||
|
} from "@azure/msal-browser";
|
||||||
|
|
||||||
|
const GRAPH_SCOPES = [
|
||||||
|
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||||
|
"https://graph.microsoft.com/User.Read",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createMsal(clientId: string): PublicClientApplication {
|
||||||
|
return new PublicClientApplication({
|
||||||
|
auth: {
|
||||||
|
clientId,
|
||||||
|
authority: "https://login.microsoftonline.com/common",
|
||||||
|
redirectUri: window.location.origin,
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
cacheLocation: "localStorage",
|
||||||
|
storeAuthStateInCookie: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeAuth(msal: IPublicClientApplication): Promise<void> {
|
||||||
|
await msal.initialize();
|
||||||
|
await msal.handleRedirectPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signIn(msal: IPublicClientApplication): Promise<AccountInfo> {
|
||||||
|
await initializeAuth(msal);
|
||||||
|
const result = await msal.loginPopup({ scopes: GRAPH_SCOPES });
|
||||||
|
if (!result.account) {
|
||||||
|
throw new Error("Sign-in did not return an account.");
|
||||||
|
}
|
||||||
|
msal.setActiveAccount(result.account);
|
||||||
|
return result.account;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call after initializeAuth when a single cached account should be used. */
|
||||||
|
export function ensureActiveAccount(msal: IPublicClientApplication): void {
|
||||||
|
if (msal.getActiveAccount()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accounts = msal.getAllAccounts();
|
||||||
|
if (accounts.length === 1) {
|
||||||
|
msal.setActiveAccount(accounts[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signOut(msal: IPublicClientApplication): void {
|
||||||
|
const account = msal.getActiveAccount() ?? msal.getAllAccounts()[0];
|
||||||
|
if (account) {
|
||||||
|
void msal.logoutPopup({ account });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGraphAccessToken(msal: IPublicClientApplication): Promise<string> {
|
||||||
|
await initializeAuth(msal);
|
||||||
|
const account = msal.getActiveAccount() ?? msal.getAllAccounts()[0];
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("Not signed in.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await msal.acquireTokenSilent({ scopes: GRAPH_SCOPES, account });
|
||||||
|
return result.accessToken;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof InteractionRequiredAuthError) {
|
||||||
|
const result = await msal.acquireTokenPopup({ scopes: GRAPH_SCOPES, account });
|
||||||
|
return result.accessToken;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveAccount(msal: IPublicClientApplication): AccountInfo | null {
|
||||||
|
return msal.getActiveAccount() ?? msal.getAllAccounts()[0] ?? null;
|
||||||
|
}
|
||||||
127
src/graph.ts
Normal file
127
src/graph.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
const GRAPH = "https://graph.microsoft.com/v1.0";
|
||||||
|
|
||||||
|
function escapeODataString(s: string): string {
|
||||||
|
return s.replace(/'/g, "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphMessage {
|
||||||
|
id: string;
|
||||||
|
subject?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphMailFolder {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function graphJson<T>(token: string, pathOrUrl: string, init?: RequestInit): Promise<T> {
|
||||||
|
const url = pathOrUrl.startsWith("https://") ? pathOrUrl : `${GRAPH}${pathOrUrl}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Graph ${res.status}: ${text || res.statusText}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInboxChildFolders(token: string): Promise<GraphMailFolder[]> {
|
||||||
|
const out: GraphMailFolder[] = [];
|
||||||
|
let next: string | null =
|
||||||
|
"/me/mailFolders/inbox/childFolders?$select=id,displayName&$top=999";
|
||||||
|
|
||||||
|
while (next) {
|
||||||
|
const page = await graphJson<{ value: GraphMailFolder[]; "@odata.nextLink"?: string }>(
|
||||||
|
token,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
out.push(...page.value);
|
||||||
|
next = page["@odata.nextLink"] ?? null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findChildFolderByName(
|
||||||
|
token: string,
|
||||||
|
displayName: string,
|
||||||
|
): Promise<GraphMailFolder | undefined> {
|
||||||
|
const folders = await listInboxChildFolders(token);
|
||||||
|
return folders.find((f) => f.displayName === displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInboxChildFolder(
|
||||||
|
token: string,
|
||||||
|
displayName: string,
|
||||||
|
): Promise<GraphMailFolder> {
|
||||||
|
return graphJson<GraphMailFolder>(token, "/me/mailFolders/inbox/childFolders", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ displayName }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findOrCreateInboxChildFolder(
|
||||||
|
token: string,
|
||||||
|
displayName: string,
|
||||||
|
): Promise<GraphMailFolder> {
|
||||||
|
const existing = await findChildFolderByName(token, displayName);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return createInboxChildFolder(token, displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMessagesMatchingFilter(
|
||||||
|
token: string,
|
||||||
|
filter: string,
|
||||||
|
): Promise<GraphMessage[]> {
|
||||||
|
const out: GraphMessage[] = [];
|
||||||
|
let next: string | null =
|
||||||
|
`/me/messages?$filter=${encodeURIComponent(filter)}&$select=id,subject&$top=999`;
|
||||||
|
|
||||||
|
while (next) {
|
||||||
|
const page = await graphJson<{ value: GraphMessage[]; "@odata.nextLink"?: string }>(
|
||||||
|
token,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
out.push(...page.value);
|
||||||
|
next = page["@odata.nextLink"] ?? null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds messages whose subject or preview contains "TrackingID#<id>".
|
||||||
|
* Falls back to subject-only filter if the tenant rejects bodyPreview in $filter.
|
||||||
|
*/
|
||||||
|
export async function findMessagesWithTrackingId(
|
||||||
|
token: string,
|
||||||
|
trackingId: string,
|
||||||
|
): Promise<GraphMessage[]> {
|
||||||
|
const needle = `TrackingID#${trackingId}`;
|
||||||
|
const q = escapeODataString(needle);
|
||||||
|
const combined = `(contains(subject,'${q}') or contains(bodyPreview,'${q}'))`;
|
||||||
|
const subjectOnly = `contains(subject,'${q}')`;
|
||||||
|
try {
|
||||||
|
return await listMessagesMatchingFilter(token, combined);
|
||||||
|
} catch {
|
||||||
|
return listMessagesMatchingFilter(token, subjectOnly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveMessageToFolder(
|
||||||
|
token: string,
|
||||||
|
messageId: string,
|
||||||
|
destinationFolderId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await graphJson<unknown>(token, `/me/messages/${encodeURIComponent(messageId)}/move`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ destinationId: destinationFolderId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
14
src/main.ts
Normal file
14
src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/// <reference types="office-js" />
|
||||||
|
import { initApp } from "./app";
|
||||||
|
|
||||||
|
Office.onReady((info) => {
|
||||||
|
const root = document.getElementById("app");
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (info.host === Office.HostType.Outlook) {
|
||||||
|
void initApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.textContent = "Open OL Rescue in Outlook (desktop or web) to use this add-in.";
|
||||||
|
});
|
||||||
28
src/parse.ts
Normal file
28
src/parse.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/** Matches internal ticket markers like TrackingID#2603260030007249 */
|
||||||
|
const TRACKING_RE = /TrackingID#(\d+)/i;
|
||||||
|
|
||||||
|
export function extractTrackingId(text: string): string | null {
|
||||||
|
const m = TRACKING_RE.exec(text);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a suggested folder name: "<id> <short title>" from a message subject line.
|
||||||
|
*/
|
||||||
|
export function suggestFolderDisplayName(subject: string, trackingId: string): string {
|
||||||
|
let title = subject.trim();
|
||||||
|
const lower = title.toLowerCase();
|
||||||
|
const idx = lower.indexOf("trackingid#");
|
||||||
|
if (idx > 0) {
|
||||||
|
title = title.slice(0, idx).trim();
|
||||||
|
title = title.replace(/\s*-\s*$/u, "").trim();
|
||||||
|
}
|
||||||
|
title = title.replace(/^(re|fw|fwd)\s*:\s*/giu, "").trim();
|
||||||
|
title = title.replace(/^\[external\]\s*/giu, "").trim();
|
||||||
|
title = title.replace(/\s+/gu, " ").trim();
|
||||||
|
if (title.length > 80) {
|
||||||
|
title = `${title.slice(0, 77)}...`;
|
||||||
|
}
|
||||||
|
const folder = `${trackingId} ${title || "Case"}`.trim();
|
||||||
|
return folder.length > 255 ? folder.slice(0, 255) : folder;
|
||||||
|
}
|
||||||
35
src/storage.ts
Normal file
35
src/storage.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export interface CaseEntry {
|
||||||
|
id: string;
|
||||||
|
trackingId: string;
|
||||||
|
folderDisplayName: string;
|
||||||
|
graphFolderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = "ol-rescue-cases-v1";
|
||||||
|
|
||||||
|
export function loadCases(): CaseEntry[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return parsed.filter(
|
||||||
|
(x): x is CaseEntry =>
|
||||||
|
typeof x === "object" &&
|
||||||
|
x !== null &&
|
||||||
|
typeof (x as CaseEntry).id === "string" &&
|
||||||
|
typeof (x as CaseEntry).trackingId === "string" &&
|
||||||
|
typeof (x as CaseEntry).folderDisplayName === "string",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCases(cases: CaseEntry[]): void {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(cases));
|
||||||
|
}
|
||||||
221
src/styles.css
Normal file
221
src/styles.css
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #0f1419;
|
||||||
|
--panel: #1a2332;
|
||||||
|
--text: #e8eef7;
|
||||||
|
--muted: #8b9cb3;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--accent-hover: #1d4ed8;
|
||||||
|
--border: #2d3a4f;
|
||||||
|
--radius: 10px;
|
||||||
|
font-family:
|
||||||
|
"Segoe UI Variable",
|
||||||
|
"Segoe UI",
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f4f6fa;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #111827;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 14px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px 14px 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log {
|
||||||
|
margin: 0;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-meta strong {
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, "Cascadia Code", monospace;
|
||||||
|
font-size: 0.88em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--muted) 18%, transparent);
|
||||||
|
}
|
||||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_AZURE_CLIENT_ID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"types": ["vite/client", "office-js"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [basicSsl()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
strictPort: true,
|
||||||
|
https: {},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user