@pablozaiden/webapp 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { Server } from "bun";
1
+ import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
2
2
  import type { CurrentUser, LogLevelName, ThemePreference, WebAppConfigResponse, WebAppUserRole } from "../contracts";
3
3
  import { authenticateApiKey, assertScopes, createApiKey, deleteApiKey, listApiKeys } from "./auth/api-keys";
4
4
  import {
@@ -50,6 +50,8 @@ export interface WebAppServerConfig<TEvent = unknown> {
50
50
  version?: string;
51
51
  store?: WebAppStore;
52
52
  routes?: RouteTable<TEvent>;
53
+ publicRoutes?: Record<string, PublicRouteDefinition>;
54
+ websockets?: Record<string, Partial<WebSocketHandler<WebAppWebSocketData>>>;
53
55
  auth?: {
54
56
  passkeys?: boolean | { rpName?: string; userName?: string; userDisplayName?: string };
55
57
  apiKeys?: boolean;
@@ -60,6 +62,24 @@ export interface WebAppServerConfig<TEvent = unknown> {
60
62
  };
61
63
  }
62
64
 
65
+ export const WEBAPP_SOCKET_HANDLER = "webappSocketHandler";
66
+
67
+ export type WebAppWebSocketData = WebSocketData & {
68
+ webappSocketHandler?: string;
69
+ [key: string]: unknown;
70
+ };
71
+
72
+ export type PublicRouteAsset = Response | Blob | ArrayBuffer | Uint8Array | string;
73
+ export type PublicRouteHandler = (req: Request) => PublicRouteAsset | undefined | Promise<PublicRouteAsset | undefined>;
74
+ export type PublicRouteValue = PublicRouteAsset | PublicRouteHandler;
75
+ export type PublicRouteDefinition =
76
+ | PublicRouteValue
77
+ | {
78
+ GET?: PublicRouteValue;
79
+ HEAD?: PublicRouteValue;
80
+ headers?: HeadersInit;
81
+ };
82
+
63
83
  export interface WebAppServer<TEvent = unknown> {
64
84
  config: RuntimeConfig;
65
85
  store: WebAppStore;
@@ -127,6 +147,20 @@ function htmlResponse(index: unknown): Response {
127
147
  return index as Response;
128
148
  }
129
149
 
150
+ function publicAssetResponse(asset: PublicRouteAsset, extraHeaders?: HeadersInit): Response {
151
+ const response = asset instanceof Response
152
+ ? asset
153
+ : typeof asset === "string"
154
+ ? new Response(asset, { headers: { "content-type": "text/plain; charset=utf-8" } })
155
+ : new Response(asset as BodyInit);
156
+ if (extraHeaders) {
157
+ for (const [name, value] of new Headers(extraHeaders)) {
158
+ response.headers.set(name, value);
159
+ }
160
+ }
161
+ return withSecurityHeaders(response);
162
+ }
163
+
130
164
  function secureDynamicResponse(response: Response): Response {
131
165
  return response instanceof Response ? withSecurityHeaders(response) : response;
132
166
  }
@@ -245,6 +279,8 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
245
279
  const version = input.version ?? "0.0.0-development";
246
280
  const wsPath = input.realtime?.path ?? "/api/ws";
247
281
  const routes = input.routes ?? {};
282
+ const publicRoutes = input.publicRoutes ?? {};
283
+ const appWebsockets = input.websockets ?? {};
248
284
  const passkeysEnabled = input.auth?.passkeys !== false;
249
285
  const apiKeysEnabled = input.auth?.apiKeys ?? false;
250
286
  const deviceAuthEnabled = input.auth?.deviceAuth ?? false;
@@ -624,78 +660,123 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
624
660
  return undefined;
625
661
  }
626
662
 
627
- async function handleRequest(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined> {
663
+ async function handlePublicRoute(req: Request): Promise<Response | undefined> {
664
+ const url = new URL(req.url);
665
+ const route = publicRoutes[url.pathname];
666
+ if (!route) {
667
+ return undefined;
668
+ }
669
+ const methodName = req.method === "HEAD" ? "HEAD" : req.method === "GET" ? "GET" : undefined;
670
+ if (!methodName) {
671
+ return withSecurityHeaders(methodNotAllowed());
672
+ }
673
+ const definition = typeof route === "object" && route !== null && !(route instanceof Response) && !(route instanceof Blob) && !(route instanceof ArrayBuffer) && !(route instanceof Uint8Array) && ("GET" in route || "HEAD" in route || "headers" in route)
674
+ ? route
675
+ : undefined;
676
+ const value = definition ? definition[methodName] ?? (methodName === "HEAD" ? definition.GET : undefined) : route as PublicRouteValue;
677
+ if (!value) {
678
+ return withSecurityHeaders(methodNotAllowed());
679
+ }
680
+ const asset = typeof value === "function" ? await value(req) : value;
681
+ if (!asset) {
682
+ return withSecurityHeaders(notFound());
683
+ }
684
+ const response = publicAssetResponse(asset, definition?.headers);
685
+ if (req.method === "HEAD") {
686
+ return new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers });
687
+ }
688
+ return response;
689
+ }
690
+
691
+ async function handleMatchedRoute(req: Request, matched: NonNullable<ReturnType<typeof matchRoute<TEvent>>>, server?: Server<WebAppWebSocketData>): Promise<Response | undefined> {
692
+ const handler = matched.route[method(req) ?? "GET"];
693
+ if (!handler) {
694
+ return withSecurityHeaders(methodNotAllowed());
695
+ }
696
+ const routeAuth = matched.route.auth ?? "required";
697
+ const required = requiresAuth(routeAuth);
698
+ const auth = await authorize(req, required);
699
+ if (auth instanceof Response) {
700
+ return withSecurityHeaders(auth);
701
+ }
702
+ try {
703
+ enforceRouteAuth(routeAuth, auth);
704
+ if (matched.route.userParam) {
705
+ const paramValue = matched.params[matched.route.userParam];
706
+ if (!paramValue) {
707
+ throw new AuthError("route_misconfigured", `Route userParam "${matched.route.userParam}" is missing from matched params`, 500);
708
+ }
709
+ assertUser(auth, paramValue);
710
+ }
711
+ if (routeAuth !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
712
+ assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
713
+ }
714
+ } catch (error) {
715
+ return withSecurityHeaders(authErrorResponse(error));
716
+ }
717
+ const current = () => requireUser(auth);
718
+ const userRealtime = {
719
+ publishChanged: (resource, options = {}) => realtime.publishChanged(resource, { ...options, target: { ...options.target, userId: current().id } }),
720
+ publishEntityChanged: (resource, id, options = {}) => realtime.publishEntityChanged(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
721
+ publishDeleted: (resource, id, options = {}) => realtime.publishDeleted(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
722
+ publishSettingsChanged: (options = {}) => realtime.publishSettingsChanged({ ...options, target: { ...options.target, userId: current().id } }),
723
+ } satisfies UserScopedRealtimePublisher<TEvent>;
724
+ const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
725
+ if (originFailure) {
726
+ return withSecurityHeaders(originFailure);
727
+ }
728
+ try {
729
+ const response = await handler(req, {
730
+ params: matched.params,
731
+ auth,
732
+ user: currentUser(auth),
733
+ requireUser: () => requireUser(auth),
734
+ requireAdmin: () => requireAdmin(auth),
735
+ requireOwner: () => requireOwner(auth),
736
+ assertUser: (userId) => assertUser(auth, userId),
737
+ filterOwned: createFilterOwned(auth),
738
+ requireOwned: createRequireOwned(auth),
739
+ realtime,
740
+ userRealtime,
741
+ server,
742
+ });
743
+ return response ? withSecurityHeaders(response) : undefined;
744
+ } catch (error) {
745
+ return withSecurityHeaders(routeHandlerErrorResponse(error));
746
+ }
747
+ }
748
+
749
+ async function handleRequest(req: Request, server?: Server<WebAppWebSocketData>): Promise<Response | undefined> {
628
750
  const url = new URL(req.url);
751
+ const publicRoute = await handlePublicRoute(req);
752
+ if (publicRoute) {
753
+ return publicRoute;
754
+ }
755
+ const matched = matchRoute(routes, url.pathname);
629
756
  if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/device") {
630
757
  const builtIn = await handleBuiltIn(req, server);
631
758
  if (builtIn) {
632
759
  return secureDynamicResponse(builtIn);
633
760
  }
634
- const matched = matchRoute(routes, url.pathname);
635
761
  if (!matched) {
636
762
  return withSecurityHeaders(notFound());
637
763
  }
638
- const handler = matched.route[method(req) ?? "GET"];
639
- if (!handler) {
640
- return withSecurityHeaders(methodNotAllowed());
641
- }
642
- const routeAuth = matched.route.auth ?? "required";
643
- const required = requiresAuth(routeAuth);
644
- const auth = await authorize(req, required);
645
- if (auth instanceof Response) {
646
- return withSecurityHeaders(auth);
647
- }
648
- try {
649
- enforceRouteAuth(routeAuth, auth);
650
- if (matched.route.userParam) {
651
- const paramValue = matched.params[matched.route.userParam];
652
- if (!paramValue) {
653
- throw new AuthError("route_misconfigured", `Route userParam "${matched.route.userParam}" is missing from matched params`, 500);
654
- }
655
- assertUser(auth, paramValue);
656
- }
657
- if (routeAuth !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
658
- assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
659
- }
660
- } catch (error) {
661
- return withSecurityHeaders(authErrorResponse(error));
662
- }
663
- const current = () => requireUser(auth);
664
- const userRealtime = {
665
- publishChanged: (resource, options = {}) => realtime.publishChanged(resource, { ...options, target: { ...options.target, userId: current().id } }),
666
- publishEntityChanged: (resource, id, options = {}) => realtime.publishEntityChanged(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
667
- publishDeleted: (resource, id, options = {}) => realtime.publishDeleted(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
668
- publishSettingsChanged: (options = {}) => realtime.publishSettingsChanged({ ...options, target: { ...options.target, userId: current().id } }),
669
- } satisfies UserScopedRealtimePublisher<TEvent>;
670
- const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
671
- if (originFailure) {
672
- return withSecurityHeaders(originFailure);
673
- }
674
- try {
675
- return withSecurityHeaders(await handler(req, {
676
- params: matched.params,
677
- auth,
678
- user: currentUser(auth),
679
- requireUser: () => requireUser(auth),
680
- requireAdmin: () => requireAdmin(auth),
681
- requireOwner: () => requireOwner(auth),
682
- assertUser: (userId) => assertUser(auth, userId),
683
- filterOwned: createFilterOwned(auth),
684
- requireOwned: createRequireOwned(auth),
685
- realtime,
686
- userRealtime,
687
- server,
688
- }));
689
- } catch (error) {
690
- return withSecurityHeaders(routeHandlerErrorResponse(error));
691
- }
764
+ return handleMatchedRoute(req, matched, server);
765
+ }
766
+ if (matched) {
767
+ return handleMatchedRoute(req, matched, server);
692
768
  }
693
769
  return htmlResponse(input.index);
694
770
  }
695
771
 
696
- function start(): Server<WebSocketData> {
697
- const dynamicHandler = (req: Request, server: Server<WebSocketData>) => handleRequest(req, server);
698
- const server = Bun.serve<WebSocketData>({
772
+ function customHandler(socket: ServerWebSocket<WebAppWebSocketData>): Partial<WebSocketHandler<WebAppWebSocketData>> | undefined {
773
+ const handlerName = socket.data.webappSocketHandler;
774
+ return handlerName ? appWebsockets[handlerName] : undefined;
775
+ }
776
+
777
+ function start(): Server<WebAppWebSocketData> {
778
+ const dynamicHandler = (req: Request, server: Server<WebAppWebSocketData>) => handleRequest(req, server);
779
+ const server = Bun.serve<WebAppWebSocketData>({
699
780
  hostname: config.host,
700
781
  port: config.port,
701
782
  routes: {
@@ -707,16 +788,34 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
707
788
  },
708
789
  websocket: {
709
790
  open(socket) {
791
+ const handler = customHandler(socket);
792
+ if (handler?.open) {
793
+ handler.open(socket);
794
+ return;
795
+ }
710
796
  realtime.add(socket);
711
797
  },
712
798
  message(socket, message) {
799
+ const handler = customHandler(socket);
800
+ if (handler?.message) {
801
+ handler.message(socket, message);
802
+ return;
803
+ }
713
804
  if (message === "ping") {
714
805
  socket.send(JSON.stringify({ type: "pong" }));
715
806
  }
716
807
  },
717
- close(socket) {
808
+ close(socket, code, reason) {
809
+ const handler = customHandler(socket);
810
+ if (handler?.close) {
811
+ handler.close(socket, code, reason);
812
+ return;
813
+ }
718
814
  realtime.remove(socket);
719
815
  },
816
+ drain(socket) {
817
+ customHandler(socket)?.drain?.(socket);
818
+ },
720
819
  },
721
820
  development: config.development,
722
821
  });
@@ -1,5 +1,6 @@
1
1
  export * from "./runtime-config";
2
2
  export * from "./routes";
3
+ export * from "./route-catalog";
3
4
  export * from "./responses";
4
5
  export * from "./create-web-app-server";
5
6
  export * from "./auth/store";
@@ -0,0 +1,147 @@
1
+ import type { HttpMethod, RouteAuth, RouteDefinition, RouteTable, SameOriginMode } from "./routes";
2
+
3
+ const HTTP_METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
4
+
5
+ export interface RouteCatalogEntry {
6
+ path: string;
7
+ cliPath: string;
8
+ methods: HttpMethod[];
9
+ auth: RouteAuth;
10
+ sameOrigin: SameOriginMode;
11
+ scopes: string[];
12
+ description?: string;
13
+ tags: string[];
14
+ requestSchema?: unknown;
15
+ querySchema?: unknown;
16
+ responseSchema?: unknown;
17
+ }
18
+
19
+ export interface RouteCatalogMatch {
20
+ entry: RouteCatalogEntry;
21
+ path: string;
22
+ params: Record<string, string>;
23
+ }
24
+
25
+ function trimSlashes(value: string): string {
26
+ return value.replace(/^\/+|\/+$/g, "");
27
+ }
28
+
29
+ function defaultCliPath(path: string): string {
30
+ return trimSlashes(path.startsWith("/api/") ? path.slice("/api/".length) : path);
31
+ }
32
+
33
+ function methodsFor(route: RouteDefinition): HttpMethod[] {
34
+ return HTTP_METHODS.filter((method) => typeof route[method] === "function");
35
+ }
36
+
37
+ export function createRouteCatalog<TEvent = unknown>(routes: RouteTable<TEvent>): RouteCatalogEntry[] {
38
+ return Object.entries(routes)
39
+ .filter(([, route]) => route.catalog !== false)
40
+ .map(([path, route]) => ({
41
+ path,
42
+ cliPath: route.cliPath ?? defaultCliPath(path),
43
+ methods: methodsFor(route),
44
+ auth: route.auth ?? "required",
45
+ sameOrigin: route.sameOrigin ?? "mutations",
46
+ scopes: route.scopes ?? [],
47
+ description: route.description,
48
+ tags: route.tags ?? [],
49
+ requestSchema: route.requestSchema,
50
+ querySchema: route.querySchema,
51
+ responseSchema: route.responseSchema,
52
+ }))
53
+ .filter((entry) => entry.methods.length > 0)
54
+ .sort((left, right) => left.path.localeCompare(right.path));
55
+ }
56
+
57
+ function normalizeApiPath(input: string): string {
58
+ const [path = ""] = input.trim().split(/[?#]/);
59
+ if (!path) {
60
+ throw new Error("API endpoint is required");
61
+ }
62
+ if (path.startsWith("/api/") || path === "/api") {
63
+ return path;
64
+ }
65
+ if (path.startsWith("api/")) {
66
+ return `/${path}`;
67
+ }
68
+ if (path.startsWith("/")) {
69
+ return path;
70
+ }
71
+ return `/api/${path}`;
72
+ }
73
+
74
+ function normalizeCliPath(input: string): string {
75
+ const [path = ""] = input.trim().split(/[?#]/);
76
+ return trimSlashes(path.startsWith("/api/") ? path.slice("/api/".length) : path);
77
+ }
78
+
79
+ function decodePathPart(value: string): string | undefined {
80
+ try {
81
+ return decodeURIComponent(value);
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ function matchPattern(pattern: string, value: string): Record<string, string> | undefined {
88
+ const patternParts = trimSlashes(pattern).split("/").filter(Boolean);
89
+ const valueParts = trimSlashes(value).split("/").filter(Boolean);
90
+ if (patternParts.length !== valueParts.length) {
91
+ return undefined;
92
+ }
93
+ const params: Record<string, string> = {};
94
+ for (let index = 0; index < patternParts.length; index += 1) {
95
+ const patternPart = patternParts[index]!;
96
+ const valuePart = valueParts[index]!;
97
+ if (patternPart.startsWith(":")) {
98
+ const decoded = decodePathPart(valuePart);
99
+ if (decoded === undefined) {
100
+ return undefined;
101
+ }
102
+ params[patternPart.slice(1)] = decoded;
103
+ continue;
104
+ }
105
+ if (patternPart !== valuePart) {
106
+ return undefined;
107
+ }
108
+ }
109
+ return params;
110
+ }
111
+
112
+ function concretePath(pattern: string, params: Record<string, string>): string {
113
+ return trimSlashes(pattern)
114
+ .split("/")
115
+ .map((part) => part.startsWith(":") ? encodeURIComponent(params[part.slice(1)] ?? "") : part)
116
+ .join("/")
117
+ .replace(/^/, "/");
118
+ }
119
+
120
+ function specificity(entry: RouteCatalogEntry): number {
121
+ return entry.path.split("/").reduce((score, part) => score + (part && !part.startsWith(":") ? 2 : 1), 0);
122
+ }
123
+
124
+ export function findRouteCatalogEntry(catalog: readonly RouteCatalogEntry[], input: string): RouteCatalogMatch | undefined {
125
+ const apiPath = normalizeApiPath(input);
126
+ const cliPath = normalizeCliPath(input);
127
+ const sorted = [...catalog].sort((left, right) => specificity(right) - specificity(left));
128
+ for (const entry of sorted) {
129
+ if (entry.path === apiPath) {
130
+ return { entry, path: apiPath, params: {} };
131
+ }
132
+ if (entry.cliPath === cliPath) {
133
+ return { entry, path: entry.path, params: {} };
134
+ }
135
+ }
136
+ for (const entry of sorted) {
137
+ const apiParams = matchPattern(entry.path, apiPath);
138
+ if (apiParams) {
139
+ return { entry, path: apiPath, params: apiParams };
140
+ }
141
+ const cliParams = matchPattern(entry.cliPath, cliPath);
142
+ if (cliParams) {
143
+ return { entry, path: concretePath(entry.path, cliParams), params: cliParams };
144
+ }
145
+ }
146
+ return undefined;
147
+ }
@@ -36,14 +36,24 @@ export interface RouteContext<TParams extends Record<string, string> = Record<st
36
36
  export type WebAppRouteHandler<TParams extends Record<string, string> = Record<string, string>, TEvent = unknown> = (
37
37
  req: Request,
38
38
  ctx: RouteContext<TParams, TEvent>,
39
- ) => Response | Promise<Response>;
39
+ ) => Response | undefined | Promise<Response | undefined>;
40
+
41
+ export interface RouteMetadata {
42
+ description?: string;
43
+ cliPath?: string;
44
+ tags?: string[];
45
+ requestSchema?: unknown;
46
+ querySchema?: unknown;
47
+ responseSchema?: unknown;
48
+ catalog?: boolean;
49
+ }
40
50
 
41
51
  export type RouteDefinition<TEvent = unknown> = {
42
52
  auth?: RouteAuth;
43
53
  sameOrigin?: SameOriginMode;
44
54
  scopes?: string[];
45
55
  userParam?: string;
46
- } & Partial<Record<HttpMethod, WebAppRouteHandler<Record<string, string>, TEvent>>>;
56
+ } & RouteMetadata & Partial<Record<HttpMethod, WebAppRouteHandler<Record<string, string>, TEvent>>>;
47
57
 
48
58
  export type RouteTable<TEvent = unknown> = Record<string, RouteDefinition<TEvent>>;
49
59
 
@@ -487,6 +487,7 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
487
487
  const [username, setUsername] = useState("");
488
488
  const [role, setRole] = useState<WebAppUserRole>("user");
489
489
  const [setupLink, setSetupLink] = useState<string>();
490
+ const [userToDelete, setUserToDelete] = useState<WebAppUserSummary>();
490
491
  const [error, setError] = useState<string>();
491
492
 
492
493
  const refreshUsers = useCallback(async () => {
@@ -535,6 +536,7 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
535
536
  try {
536
537
  setError(undefined);
537
538
  await json(`/api/users/${encodeURIComponent(user.id)}`, { method: "DELETE" });
539
+ setUserToDelete(undefined);
538
540
  await refreshUsers();
539
541
  } catch (err) {
540
542
  setError(err instanceof Error ? err.message : String(err));
@@ -575,23 +577,73 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
575
577
  </select>
576
578
  ) : <Badge variant="success">Owner</Badge>}
577
579
  {user.role !== "owner" ? <Button type="button" onClick={() => void resetUser(user)}>Reset</Button> : null}
578
- <Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() => void deleteUser(user)}>Delete</Button>
580
+ <Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() => setUserToDelete(user)}>Delete</Button>
579
581
  </div>
580
582
  </div>
581
583
  ))}
582
584
  </div>
585
+ <ConfirmDialog
586
+ open={Boolean(userToDelete)}
587
+ title="Delete user?"
588
+ message={userToDelete ? `This permanently deletes "${userToDelete.username}" and revokes their setup links, API keys, passkeys and device sessions.` : ""}
589
+ confirmLabel="Delete user"
590
+ danger
591
+ onCancel={() => setUserToDelete(undefined)}
592
+ onConfirm={() => userToDelete && void deleteUser(userToDelete)}
593
+ />
583
594
  </FormSection>
584
595
  );
585
596
  }
586
597
 
598
+ const KILL_SERVER_COUNTDOWN_SECONDS = 15;
599
+
600
+ function computeProgressPercent(countdown: number, total: number): number {
601
+ return total <= 0 ? 0 : (countdown / total) * 100;
602
+ }
603
+
604
+ function useCountdownReload(active: boolean, onComplete: () => void, durationSeconds = KILL_SERVER_COUNTDOWN_SECONDS): { countdown: number; progressPercent: number } {
605
+ const [countdown, setCountdown] = useState(durationSeconds);
606
+
607
+ useEffect(() => {
608
+ if (!active) {
609
+ setCountdown(durationSeconds);
610
+ return;
611
+ }
612
+
613
+ setCountdown(durationSeconds);
614
+ const interval = window.setInterval(() => {
615
+ setCountdown((previous) => {
616
+ const next = previous - 1;
617
+ if (next <= 0) {
618
+ window.clearInterval(interval);
619
+ onComplete();
620
+ return 0;
621
+ }
622
+ return next;
623
+ });
624
+ }, 1000);
625
+
626
+ return () => window.clearInterval(interval);
627
+ }, [active, durationSeconds, onComplete]);
628
+
629
+ return {
630
+ countdown,
631
+ progressPercent: computeProgressPercent(countdown, durationSeconds),
632
+ };
633
+ }
634
+
587
635
  function SettingsView({ config, refresh, customSections, theme, setTheme }: { config: WebAppConfigResponse; refresh: () => Promise<void>; customSections: SettingsSection[]; theme: ThemePreference; setTheme: (theme: ThemePreference) => void }) {
588
636
  const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([]);
589
637
  const [authSessions, setAuthSessions] = useState<AuthSessionSummary[]>([]);
590
638
  const [createdToken, setCreatedToken] = useState<string>();
591
639
  const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeySummary>();
640
+ const [authSessionToRevoke, setAuthSessionToRevoke] = useState<AuthSessionSummary>();
592
641
  const [confirmDeletePasskey, setConfirmDeletePasskey] = useState(false);
642
+ const [confirmKillServer, setConfirmKillServer] = useState(false);
593
643
  const [killRequested, setKillRequested] = useState(false);
594
644
  const [error, setError] = useState<string>();
645
+ const reloadPage = useCallback(() => window.location.reload(), []);
646
+ const { countdown, progressPercent } = useCountdownReload(killRequested, reloadPage);
595
647
 
596
648
  const refreshApiKeys = useCallback(async () => {
597
649
  if (config.apiKeys.enabled) {
@@ -648,10 +700,25 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
648
700
  await refresh();
649
701
  }
650
702
 
703
+ async function revokeAuthSession(session: AuthSessionSummary) {
704
+ try {
705
+ setError(undefined);
706
+ await json(`/api/auth/sessions/${session.id}`, { method: "DELETE" });
707
+ setAuthSessionToRevoke(undefined);
708
+ await refreshAuthSessions();
709
+ } catch (err) {
710
+ setError(err instanceof Error ? err.message : String(err));
711
+ }
712
+ }
713
+
651
714
  async function killServer() {
715
+ setError(undefined);
716
+ setConfirmKillServer(false);
717
+ const response = await fetch("/api/server/kill", { method: "POST" });
718
+ if (!response.ok) {
719
+ throw new Error("Failed to kill server. Please try again.");
720
+ }
652
721
  setKillRequested(true);
653
- await fetch("/api/server/kill", { method: "POST" });
654
- setTimeout(() => window.location.reload(), 3500);
655
722
  }
656
723
 
657
724
  return (
@@ -729,7 +796,7 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
729
796
  {authSessions.length ? authSessions.map((session) => (
730
797
  <div className="wapp-list-row" key={session.id}>
731
798
  <span><strong>{session.clientId}</strong><small>{session.scope} · {session.active ? "active" : "inactive"} · {session.updatedAt}</small></span>
732
- <Button type="button" variant="danger" disabled={!session.active} onClick={() => void json(`/api/auth/sessions/${session.id}`, { method: "DELETE" }).then(refreshAuthSessions)}>Revoke</Button>
799
+ <Button type="button" variant="danger" disabled={!session.active} onClick={() => setAuthSessionToRevoke(session)}>Revoke</Button>
733
800
  </div>
734
801
  )) : <EmptyState title="No device sessions" />}
735
802
  </div>
@@ -741,8 +808,23 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
741
808
 
742
809
  {config.currentUser?.isAdmin ? (
743
810
  <FormSection title="Server operations">
744
- {killRequested ? <p className="wapp-notice">Server is shutting down. Reloading soon...</p> : null}
745
- <Button type="button" variant="danger" onClick={() => void killServer().catch((err) => setError(String(err)))}>Kill server</Button>
811
+ <div className="wapp-settings-row">
812
+ <div>
813
+ <strong>Kill server</strong>
814
+ <p>Stop the server process. If your deployment restarts it automatically, the app will come back after a moment.</p>
815
+ {killRequested ? (
816
+ <div className="wapp-shutdown-countdown" aria-live="polite">
817
+ <div className="wapp-shutdown-message">Server is shutting down... Reloading in {countdown}s</div>
818
+ <div className="wapp-shutdown-progress" aria-hidden="true">
819
+ <div className="wapp-shutdown-progress-bar" style={{ width: `${progressPercent}%` }} />
820
+ </div>
821
+ </div>
822
+ ) : null}
823
+ </div>
824
+ <div className="wapp-row-actions">
825
+ <Button type="button" variant="danger" disabled={killRequested} onClick={() => setConfirmKillServer(true)}>Kill server</Button>
826
+ </div>
827
+ </div>
746
828
  </FormSection>
747
829
  ) : null}
748
830
 
@@ -765,6 +847,24 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
765
847
  onConfirm={() => apiKeyToDelete && void deleteKey(apiKeyToDelete.id)}
766
848
  />
767
849
  <ConfirmDialog open={confirmDeletePasskey} title="Delete passkey?" message="This removes the configured passkey and invalidates browser sessions." confirmLabel="Delete passkey" danger onCancel={() => setConfirmDeletePasskey(false)} onConfirm={() => void deleteConfiguredPasskey()} />
850
+ <ConfirmDialog
851
+ open={Boolean(authSessionToRevoke)}
852
+ title="Revoke device session?"
853
+ message={authSessionToRevoke ? `This revokes the active "${authSessionToRevoke.clientId}" refresh-token session.` : ""}
854
+ confirmLabel="Revoke session"
855
+ danger
856
+ onCancel={() => setAuthSessionToRevoke(undefined)}
857
+ onConfirm={() => authSessionToRevoke && void revokeAuthSession(authSessionToRevoke)}
858
+ />
859
+ <ConfirmDialog
860
+ open={confirmKillServer}
861
+ title="Kill server?"
862
+ message="Are you sure you want to kill the server?"
863
+ confirmLabel="Kill server"
864
+ danger
865
+ onCancel={() => setConfirmKillServer(false)}
866
+ onConfirm={() => void killServer().catch((err) => setError(String(err)))}
867
+ />
768
868
  </div>
769
869
  );
770
870
  }