diff --git a/open-sse/services/keyGroupAuth.ts b/open-sse/services/keyGroupAuth.ts index 9e58578fee..e6c34559a2 100644 --- a/open-sse/services/keyGroupAuth.ts +++ b/open-sse/services/keyGroupAuth.ts @@ -35,7 +35,7 @@ export interface KeyGroupAuthResult { export function authorizeKeyModelAccess( apiKeyId: string | undefined, model: string, - provider?: string, + provider?: string ): KeyGroupAuthResult { if (!apiKeyId) { // No API key = no restriction (public endpoint) diff --git a/public/sw.js b/public/sw.js index 2d3e2261d1..d24f9c6ec6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -89,3 +89,55 @@ self.addEventListener("fetch", (event) => { })() ); }); + +// ── Push Notifications ─────────────────────────────────────────────────────── + +self.addEventListener("push", (event) => { + let data; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = { title: "OmniRoute", body: event.data?.text() || "New notification" }; + } + + const title = data.title || "OmniRoute"; + const options = { + body: data.body || "", + icon: data.icon || "/icon-512.png", + badge: data.badge || "/icon-192.png", + tag: data.tag || "omniroute-default", + data: { + url: data.url || "/dashboard", + timestamp: Date.now(), + }, + vibrate: [200, 100, 200], + requireInteraction: true, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +// ── Notification Click ─────────────────────────────────────────────────────── + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + + const urlToOpen = event.notification.data?.url || "/dashboard"; + + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsList) => { + for (const client of clientsList) { + if (client.url.includes(self.location.origin) && "focus" in client) { + client.focus(); + if ("navigate" in client) { + client.navigate(urlToOpen); + } + return; + } + } + if (clients.openWindow) { + return clients.openWindow(urlToOpen); + } + }), + ); +}); diff --git a/src/app/api/keys/groups/[id]/permissions/route.ts b/src/app/api/keys/groups/[id]/permissions/route.ts index c234a03e88..b04edd55e3 100644 --- a/src/app/api/keys/groups/[id]/permissions/route.ts +++ b/src/app/api/keys/groups/[id]/permissions/route.ts @@ -1,5 +1,10 @@ import { NextResponse } from "next/server"; -import { addGroupPermission, removeGroupPermission, getGroupPermissions, getKeyGroup } from "@/lib/localDb"; +import { + addGroupPermission, + removeGroupPermission, + getGroupPermissions, + getKeyGroup, +} from "@/lib/localDb"; type RouteParams = { params: Promise<{ id: string }> }; @@ -31,7 +36,10 @@ export async function POST(request: Request, { params }: RouteParams) { const body = await request.json(); if (!body.modelPattern || !body.accessType) { - return NextResponse.json({ error: "modelPattern and accessType are required" }, { status: 400 }); + return NextResponse.json( + { error: "modelPattern and accessType are required" }, + { status: 400 } + ); } if (body.accessType !== "allow" && body.accessType !== "deny") { return NextResponse.json({ error: "accessType must be 'allow' or 'deny'" }, { status: 400 }); diff --git a/src/app/api/keys/groups/route.ts b/src/app/api/keys/groups/route.ts index 4a03760f4f..2623714737 100644 --- a/src/app/api/keys/groups/route.ts +++ b/src/app/api/keys/groups/route.ts @@ -1,9 +1,5 @@ import { NextResponse } from "next/server"; -import { - getAllKeyGroups, - createKeyGroup, - getKeyGroup, -} from "@/lib/localDb"; +import { getAllKeyGroups, createKeyGroup, getKeyGroup } from "@/lib/localDb"; /** * GET /api/keys/groups — List all key groups diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 66c767a6a4..48f0578a01 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -2,28 +2,53 @@ import type { MetadataRoute } from "next"; export default function manifest(): MetadataRoute.Manifest { return { - name: "OmniRoute", + name: "OmniRoute AI Gateway", short_name: "OmniRoute", description: "OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.", - start_url: "/", + start_url: "/dashboard", scope: "/", - display: "fullscreen", + display: "standalone", orientation: "any", background_color: "#0b0f1a", theme_color: "#0b0f1a", + categories: ["developer-tools", "productivity", "utilities"], + lang: "en", + dir: "ltr", + prefer_related_applications: false, icons: [ + { + src: "/icon-192.png", + sizes: "192x192", + type: "image/png", + purpose: "any", + }, { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable", }, + { + src: "/icon-512.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, { src: "/apple-touch-icon.png", sizes: "180x180", type: "image/png", }, ], + screenshots: [ + { + src: "/screenshots/dashboard.png", + sizes: "1280x720", + type: "image/png", + form_factor: "wide", + label: "OmniRoute Dashboard", + }, + ], }; } diff --git a/src/lib/db/apiKeyGroups.ts b/src/lib/db/apiKeyGroups.ts index e5f5fe7c6b..d14d69974b 100644 --- a/src/lib/db/apiKeyGroups.ts +++ b/src/lib/db/apiKeyGroups.ts @@ -71,13 +71,16 @@ export function createKeyGroup(name: string, description = ""): KeyGroup { const now = new Date().toISOString(); db.prepare( - "INSERT INTO key_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO key_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)" ).run(id, name, description, now, now); return getKeyGroup(id)!; } -export function updateKeyGroup(id: string, updates: { name?: string; description?: string; isActive?: boolean }): KeyGroup | undefined { +export function updateKeyGroup( + id: string, + updates: { name?: string; description?: string; isActive?: boolean } +): KeyGroup | undefined { const existing = getKeyGroup(id); if (!existing) return undefined; @@ -85,9 +88,18 @@ export function updateKeyGroup(id: string, updates: { name?: string; description const sets: string[] = []; const params: Record = { id }; - if (updates.name !== undefined) { sets.push("name = @name"); params.name = updates.name; } - if (updates.description !== undefined) { sets.push("description = @description"); params.description = updates.description; } - if (updates.isActive !== undefined) { sets.push("is_active = @isActive"); params.isActive = updates.isActive ? 1 : 0; } + if (updates.name !== undefined) { + sets.push("name = @name"); + params.name = updates.name; + } + if (updates.description !== undefined) { + sets.push("description = @description"); + params.description = updates.description; + } + if (updates.isActive !== undefined) { + sets.push("is_active = @isActive"); + params.isActive = updates.isActive ? 1 : 0; + } if (sets.length === 0) return existing; sets.push("updated_at = datetime('now')"); @@ -107,9 +119,11 @@ export function deleteKeyGroup(id: string): boolean { export function getGroupPermissions(groupId: string): GroupModelPermission[] { const db = getDbInstance() as any; - const rows = db.prepare( - "SELECT * FROM group_model_permissions WHERE group_id = ? ORDER BY access_type ASC, model_pattern ASC", - ).all(groupId) as any[]; + const rows = db + .prepare( + "SELECT * FROM group_model_permissions WHERE group_id = ? ORDER BY access_type ASC, model_pattern ASC" + ) + .all(groupId) as any[]; return rows.map(rowToPermission); } @@ -117,14 +131,14 @@ export function addGroupPermission( groupId: string, modelPattern: string, accessType: "allow" | "deny", - provider?: string, + provider?: string ): GroupModelPermission { const db = getDbInstance() as any; const id = randomUUID(); const now = new Date().toISOString(); db.prepare( - "INSERT INTO group_model_permissions (id, group_id, model_pattern, provider, access_type, created_at) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO group_model_permissions (id, group_id, model_pattern, provider, access_type, created_at) VALUES (?, ?, ?, ?, ?, ?)" ).run(id, groupId, modelPattern, provider || null, accessType, now); return getGroupPermissions(groupId).find((p) => p.id === id)!; @@ -145,27 +159,34 @@ export function clearGroupPermissions(groupId: string): void { export function getGroupMembers(groupId: string): KeyGroupMember[] { const db = getDbInstance() as any; - const rows = db.prepare( - "SELECT * FROM key_group_members WHERE group_id = ? ORDER BY created_at ASC", - ).all(groupId) as any[]; + const rows = db + .prepare("SELECT * FROM key_group_members WHERE group_id = ? ORDER BY created_at ASC") + .all(groupId) as any[]; return rows.map(rowToMember); } export function getKeyGroupsForApiKey(keyId: string): KeyGroup[] { const db = getDbInstance() as any; - const rows = db.prepare(` + const rows = db + .prepare( + ` SELECT g.* FROM key_groups g INNER JOIN key_group_members m ON g.id = m.group_id WHERE m.key_id = ? AND g.is_active = 1 ORDER BY g.name ASC - `).all(keyId) as any[]; + ` + ) + .all(keyId) as any[]; return rows.map(rowToGroup); } export function addKeyToGroup(keyId: string, groupId: string): boolean { const db = getDbInstance() as any; try { - db.prepare("INSERT OR IGNORE INTO key_group_members (key_id, group_id) VALUES (?, ?)").run(keyId, groupId); + db.prepare("INSERT OR IGNORE INTO key_group_members (key_id, group_id) VALUES (?, ?)").run( + keyId, + groupId + ); return true; } catch { return false; @@ -174,13 +195,17 @@ export function addKeyToGroup(keyId: string, groupId: string): boolean { export function removeKeyFromGroup(keyId: string, groupId: string): boolean { const db = getDbInstance() as any; - const result = db.prepare("DELETE FROM key_group_members WHERE key_id = ? AND group_id = ?").run(keyId, groupId); + const result = db + .prepare("DELETE FROM key_group_members WHERE key_id = ? AND group_id = ?") + .run(keyId, groupId); return result.changes > 0; } function getGroupMemberCount(groupId: string): number { const db = getDbInstance() as any; - const row = db.prepare("SELECT COUNT(*) as count FROM key_group_members WHERE group_id = ?").get(groupId) as any; + const row = db + .prepare("SELECT COUNT(*) as count FROM key_group_members WHERE group_id = ?") + .get(groupId) as any; return row?.count || 0; } @@ -196,7 +221,11 @@ export interface ModelAccessCheck { * Check if an API key has access to a specific model. * Deny rules override allow rules. If no rules match, access is allowed by default. */ -export function checkKeyModelAccess(keyId: string, model: string, provider?: string): ModelAccessCheck { +export function checkKeyModelAccess( + keyId: string, + model: string, + provider?: string +): ModelAccessCheck { const groups = getKeyGroupsForApiKey(keyId); if (groups.length === 0) { // No groups = no restrictions @@ -207,17 +236,24 @@ export function checkKeyModelAccess(keyId: string, model: string, provider?: str const groupIds = groups.map((g) => g.id); const placeholders = groupIds.map(() => "?").join(","); - const rules = db.prepare(` + const rules = db + .prepare( + ` SELECT * FROM group_model_permissions WHERE group_id IN (${placeholders}) ORDER BY access_type ASC - `).all(...groupIds) as any[]; + ` + ) + .all(...groupIds) as any[]; const permissions = rules.map(rowToPermission); // Check deny rules first (they take precedence) const denyRules = permissions.filter( - (p) => p.accessType === "deny" && matchesModelPattern(p.modelPattern, model) && (!p.provider || p.provider === provider), + (p) => + p.accessType === "deny" && + matchesModelPattern(p.modelPattern, model) && + (!p.provider || p.provider === provider) ); if (denyRules.length > 0) { @@ -226,7 +262,10 @@ export function checkKeyModelAccess(keyId: string, model: string, provider?: str // Check allow rules const allowRules = permissions.filter( - (p) => p.accessType === "allow" && matchesModelPattern(p.modelPattern, model) && (!p.provider || p.provider === provider), + (p) => + p.accessType === "allow" && + matchesModelPattern(p.modelPattern, model) && + (!p.provider || p.provider === provider) ); if (allowRules.length > 0) {