Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/gittensory-ui/public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "Gittensory Control Panel",
"short_name": "Gittensory",
"start_url": "/app",
"scope": "/",
"display": "standalone",
"background_color": "#0a1714",
"theme_color": "#0a1714",
"description": "Control-panel workflows for maintainer and operator checks with opt-in browser notifications.",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
139 changes: 139 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9793,6 +9793,145 @@
]
}
},
"/v1/app/notification-model": {
"get": {
"responses": {
"200": {
"description": "Opt-in notification model and PWA-readiness metadata for control-panel routes",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"generatedAt": {
"type": "string"
},
"notificationModel": {
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": [
"opt_in"
]
},
"defaultState": {
"type": "string",
"enum": [
"disabled"
]
},
"channels": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"transport": {
"type": "string",
"enum": [
"in_app",
"web_push"
]
},
"defaultEnabled": {
"type": "boolean"
},
"requiresPermission": {
"type": "boolean"
},
"purpose": {
"type": "string"
}
},
"required": [
"id",
"transport",
"defaultEnabled",
"purpose"
]
}
},
"privacyGuards": {
"type": "array",
"items": {
"type": "string"
}
},
"fallbackWhenUnavailable": {
"type": "string",
"enum": [
"in_app_digest_only"
]
}
},
"required": [
"mode",
"defaultState",
"channels",
"privacyGuards",
"fallbackWhenUnavailable"
]
},
"pwa": {
"type": "object",
"properties": {
"nativeDependency": {
"type": "boolean"
},
"manifestPath": {
"type": "string"
},
"serviceWorkerPath": {
"type": "string"
}
},
"required": [
"nativeDependency",
"manifestPath",
"serviceWorkerPath"
]
},
"mobileReadyRoutes": {
"type": "array",
"items": {
"type": "string"
}
},
"nativeMobileFuture": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"generatedAt",
"notificationModel",
"pwa",
"mobileReadyRoutes",
"nativeMobileFuture"
]
}
}
}
},
"403": {
"description": "Role does not allow control-panel notification model access"
}
},
"security": [
{
"GittensoryBearer": []
},
{
"GittensorySessionCookie": []
}
]
}
},
"/v1/repos": {
"get": {
"responses": {
Expand Down
12 changes: 12 additions & 0 deletions apps/gittensory-ui/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting());
});

self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});

self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(self.clients.openWindow("/app"));
});
4 changes: 4 additions & 0 deletions apps/gittensory-ui/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ React.startTransition(() => {
React.createElement(React.StrictMode, null, React.createElement(StartClient)),
);
});

if ("serviceWorker" in navigator && window.isSecureContext) {
void navigator.serviceWorker.register("/sw.js").catch(() => undefined);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { Bell, BellOff } from "lucide-react";
import { useMemo, useState } from "react";

import { StatusPill } from "./control-primitives";
import { useApiResource } from "@/lib/api/use-api-resource";
import { useLocalStorage } from "@/lib/use-local-storage";

type NotificationModelResponse = {
notificationModel: {
mode: "opt_in";
defaultState: "disabled";
channels: Array<{
id: string;
transport: "in_app" | "web_push";
defaultEnabled: boolean;
requiresPermission?: boolean;
purpose: string;
}>;
privacyGuards: string[];
fallbackWhenUnavailable: string;
};
pwa: { nativeDependency: boolean; manifestPath: string; serviceWorkerPath: string };
mobileReadyRoutes: string[];
nativeMobileFuture: string[];
};

export function NotificationReadinessCard() {
const model = useApiResource<NotificationModelResponse>(
"/v1/app/notification-model",
"Notification model",
);
const [optIn, setOptIn] = useLocalStorage<boolean>("gittensory_notification_opt_in", false);
const [busy, setBusy] = useState(false);

const permission = typeof Notification === "undefined" ? "unsupported" : Notification.permission;
const canAskPermission = permission !== "unsupported" && permission !== "granted";
const pushChannel = useMemo(
() =>
model.status === "ready"
? model.data.notificationModel.channels.find((channel) => channel.id === "browser_push")
: null,
[model],
);

async function enableNotifications() {
if (!canAskPermission || busy) return;
setBusy(true);
try {
const result = await Notification.requestPermission();
if (result === "granted") setOptIn(true);
} finally {
setBusy(false);
}
}

return (
<section className="rounded-token border border-border bg-transparent p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="font-display text-token-lg font-semibold">Notification readiness</h2>
<StatusPill status={optIn ? "ready" : "warn"}>
{optIn ? "opt-in enabled" : "opt-in required"}
</StatusPill>
</div>
{model.status === "ready" ? (
<div className="mt-3 space-y-3 text-token-sm">
<p className="text-muted-foreground">
Delivery mode is <strong>{model.data.notificationModel.mode}</strong> and defaults to{" "}
<strong>{model.data.notificationModel.defaultState}</strong>.
</p>
<ul className="space-y-1 text-foreground/90">
{model.data.notificationModel.channels.map((channel) => (
<li key={channel.id}>
· {channel.id}: {channel.purpose}
</li>
))}
</ul>
<p className="text-token-xs text-muted-foreground">
Fallback: {model.data.notificationModel.fallbackWhenUnavailable}. Native app dependency:{" "}
{model.data.pwa.nativeDependency ? "yes" : "no"}.
</p>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void enableNotifications()}
disabled={!canAskPermission || busy}
className="inline-flex items-center gap-2 rounded-token border border-border px-3 py-1.5 text-token-xs text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
>
<Bell className="size-3.5" />
Enable browser notifications
</button>
<button
type="button"
onClick={() => setOptIn(false)}
className="inline-flex items-center gap-2 rounded-token border border-border px-3 py-1.5 text-token-xs text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<BellOff className="size-3.5" />
Disable on this device
</button>
{pushChannel?.requiresPermission ? (
<span className="text-token-2xs text-muted-foreground">
Browser permission: {permission}
</span>
) : null}
</div>
<ul className="space-y-1 text-token-xs text-muted-foreground">
{model.data.notificationModel.privacyGuards.map((guard) => (
<li key={guard}>· {guard}</li>
))}
</ul>
</div>
) : (
<p className="mt-3 text-token-sm text-muted-foreground">
{model.status === "loading"
? "Loading notification model…"
: "Notification model unavailable."}
</p>
)}
</section>
);
}
2 changes: 2 additions & 0 deletions apps/gittensory-ui/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
],
links: [
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
{ rel: "manifest", href: "/manifest.webmanifest" },
{ rel: "apple-touch-icon", href: "/favicon.svg" },
{ rel: "stylesheet", href: appCss },
{
rel: "preconnect",
Expand Down
2 changes: 2 additions & 0 deletions apps/gittensory-ui/src/routes/app.operator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Stat,
StatusPill,
} from "@/components/site/control-primitives";
import { NotificationReadinessCard } from "@/components/site/notification-readiness-card";
import { StateBoundary } from "@/components/site/state-views";
import { useApiResource } from "@/lib/api/use-api-resource";

Expand Down Expand Up @@ -123,6 +124,7 @@ function OperatorDashboard() {
) : null}
</div>
</section>
<NotificationReadinessCard />
</div>
) : null}
</StateBoundary>
Expand Down
43 changes: 43 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,49 @@ export function createApp() {
});
});

app.get("/v1/app/notification-model", async (c) => {
const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]);
if (forbidden) return forbidden;
return c.json({
generatedAt: nowIso(),
notificationModel: {
mode: "opt_in",
defaultState: "disabled",
channels: [
{
id: "in_app_digest",
transport: "in_app",
defaultEnabled: true,
purpose: "Show control-panel digest and attention items after authenticated sign-in.",
},
{
id: "browser_push",
transport: "web_push",
defaultEnabled: false,
requiresPermission: true,
purpose: "Optional browser push alerts for install health and drift warnings.",
},
],
privacyGuards: [
"Never include wallets, hotkeys, payout/reward estimates, raw trust scores, or farming language.",
"Require authenticated browser session before showing private maintainer/operator notification details.",
"Keep delivery opt-in and user-controlled on each device.",
],
fallbackWhenUnavailable: "in_app_digest_only",
},
pwa: {
nativeDependency: false,
manifestPath: "/manifest.webmanifest",
serviceWorkerPath: "/sw.js",
},
mobileReadyRoutes: ["/app", "/app/runs", "/app/repos", "/app/maintainer", "/app/operator"],
nativeMobileFuture: [
"OS-level background sync for alerts when browser is closed.",
"Per-device biometric re-auth and secure lock-screen notification handling.",
],
});
});

app.get("/v1/app/analytics/mcp-compatibility", async (c) => {
const forbidden = await requireAppRole(c, ["operator"]);
if (forbidden) return forbidden;
Expand Down
Loading