@pablozaiden/webapp 0.2.0 → 0.2.2

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.
@@ -40,6 +40,7 @@ export interface WebAppRootProps {
40
40
  appName: string;
41
41
  homeRoute: WebAppRoute;
42
42
  sidebar: {
43
+ search?: boolean;
43
44
  topActions?: SidebarAction[];
44
45
  pinning?: false | {
45
46
  sectionTitle?: string;
@@ -487,6 +488,7 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
487
488
  const [username, setUsername] = useState("");
488
489
  const [role, setRole] = useState<WebAppUserRole>("user");
489
490
  const [setupLink, setSetupLink] = useState<string>();
491
+ const [userToDelete, setUserToDelete] = useState<WebAppUserSummary>();
490
492
  const [error, setError] = useState<string>();
491
493
 
492
494
  const refreshUsers = useCallback(async () => {
@@ -535,6 +537,7 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
535
537
  try {
536
538
  setError(undefined);
537
539
  await json(`/api/users/${encodeURIComponent(user.id)}`, { method: "DELETE" });
540
+ setUserToDelete(undefined);
538
541
  await refreshUsers();
539
542
  } catch (err) {
540
543
  setError(err instanceof Error ? err.message : String(err));
@@ -575,23 +578,73 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
575
578
  </select>
576
579
  ) : <Badge variant="success">Owner</Badge>}
577
580
  {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>
581
+ <Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() => setUserToDelete(user)}>Delete</Button>
579
582
  </div>
580
583
  </div>
581
584
  ))}
582
585
  </div>
586
+ <ConfirmDialog
587
+ open={Boolean(userToDelete)}
588
+ title="Delete user?"
589
+ message={userToDelete ? `This permanently deletes "${userToDelete.username}" and revokes their setup links, API keys, passkeys and device sessions.` : ""}
590
+ confirmLabel="Delete user"
591
+ danger
592
+ onCancel={() => setUserToDelete(undefined)}
593
+ onConfirm={() => userToDelete && void deleteUser(userToDelete)}
594
+ />
583
595
  </FormSection>
584
596
  );
585
597
  }
586
598
 
599
+ const KILL_SERVER_COUNTDOWN_SECONDS = 15;
600
+
601
+ function computeProgressPercent(countdown: number, total: number): number {
602
+ return total <= 0 ? 0 : (countdown / total) * 100;
603
+ }
604
+
605
+ function useCountdownReload(active: boolean, onComplete: () => void, durationSeconds = KILL_SERVER_COUNTDOWN_SECONDS): { countdown: number; progressPercent: number } {
606
+ const [countdown, setCountdown] = useState(durationSeconds);
607
+
608
+ useEffect(() => {
609
+ if (!active) {
610
+ setCountdown(durationSeconds);
611
+ return;
612
+ }
613
+
614
+ setCountdown(durationSeconds);
615
+ const interval = window.setInterval(() => {
616
+ setCountdown((previous) => {
617
+ const next = previous - 1;
618
+ if (next <= 0) {
619
+ window.clearInterval(interval);
620
+ onComplete();
621
+ return 0;
622
+ }
623
+ return next;
624
+ });
625
+ }, 1000);
626
+
627
+ return () => window.clearInterval(interval);
628
+ }, [active, durationSeconds, onComplete]);
629
+
630
+ return {
631
+ countdown,
632
+ progressPercent: computeProgressPercent(countdown, durationSeconds),
633
+ };
634
+ }
635
+
587
636
  function SettingsView({ config, refresh, customSections, theme, setTheme }: { config: WebAppConfigResponse; refresh: () => Promise<void>; customSections: SettingsSection[]; theme: ThemePreference; setTheme: (theme: ThemePreference) => void }) {
588
637
  const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([]);
589
638
  const [authSessions, setAuthSessions] = useState<AuthSessionSummary[]>([]);
590
639
  const [createdToken, setCreatedToken] = useState<string>();
591
640
  const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeySummary>();
641
+ const [authSessionToRevoke, setAuthSessionToRevoke] = useState<AuthSessionSummary>();
592
642
  const [confirmDeletePasskey, setConfirmDeletePasskey] = useState(false);
643
+ const [confirmKillServer, setConfirmKillServer] = useState(false);
593
644
  const [killRequested, setKillRequested] = useState(false);
594
645
  const [error, setError] = useState<string>();
646
+ const reloadPage = useCallback(() => window.location.reload(), []);
647
+ const { countdown, progressPercent } = useCountdownReload(killRequested, reloadPage);
595
648
 
596
649
  const refreshApiKeys = useCallback(async () => {
597
650
  if (config.apiKeys.enabled) {
@@ -648,10 +701,25 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
648
701
  await refresh();
649
702
  }
650
703
 
704
+ async function revokeAuthSession(session: AuthSessionSummary) {
705
+ try {
706
+ setError(undefined);
707
+ await json(`/api/auth/sessions/${session.id}`, { method: "DELETE" });
708
+ setAuthSessionToRevoke(undefined);
709
+ await refreshAuthSessions();
710
+ } catch (err) {
711
+ setError(err instanceof Error ? err.message : String(err));
712
+ }
713
+ }
714
+
651
715
  async function killServer() {
716
+ setError(undefined);
717
+ setConfirmKillServer(false);
718
+ const response = await fetch("/api/server/kill", { method: "POST" });
719
+ if (!response.ok) {
720
+ throw new Error("Failed to kill server. Please try again.");
721
+ }
652
722
  setKillRequested(true);
653
- await fetch("/api/server/kill", { method: "POST" });
654
- setTimeout(() => window.location.reload(), 3500);
655
723
  }
656
724
 
657
725
  return (
@@ -729,7 +797,7 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
729
797
  {authSessions.length ? authSessions.map((session) => (
730
798
  <div className="wapp-list-row" key={session.id}>
731
799
  <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>
800
+ <Button type="button" variant="danger" disabled={!session.active} onClick={() => setAuthSessionToRevoke(session)}>Revoke</Button>
733
801
  </div>
734
802
  )) : <EmptyState title="No device sessions" />}
735
803
  </div>
@@ -741,8 +809,23 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
741
809
 
742
810
  {config.currentUser?.isAdmin ? (
743
811
  <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>
812
+ <div className="wapp-settings-row">
813
+ <div>
814
+ <strong>Kill server</strong>
815
+ <p>Stop the server process. If your deployment restarts it automatically, the app will come back after a moment.</p>
816
+ {killRequested ? (
817
+ <div className="wapp-shutdown-countdown" aria-live="polite">
818
+ <div className="wapp-shutdown-message">Server is shutting down... Reloading in {countdown}s</div>
819
+ <div className="wapp-shutdown-progress" aria-hidden="true">
820
+ <div className="wapp-shutdown-progress-bar" style={{ width: `${progressPercent}%` }} />
821
+ </div>
822
+ </div>
823
+ ) : null}
824
+ </div>
825
+ <div className="wapp-row-actions">
826
+ <Button type="button" variant="danger" disabled={killRequested} onClick={() => setConfirmKillServer(true)}>Kill server</Button>
827
+ </div>
828
+ </div>
746
829
  </FormSection>
747
830
  ) : null}
748
831
 
@@ -765,6 +848,24 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
765
848
  onConfirm={() => apiKeyToDelete && void deleteKey(apiKeyToDelete.id)}
766
849
  />
767
850
  <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()} />
851
+ <ConfirmDialog
852
+ open={Boolean(authSessionToRevoke)}
853
+ title="Revoke device session?"
854
+ message={authSessionToRevoke ? `This revokes the active "${authSessionToRevoke.clientId}" refresh-token session.` : ""}
855
+ confirmLabel="Revoke session"
856
+ danger
857
+ onCancel={() => setAuthSessionToRevoke(undefined)}
858
+ onConfirm={() => authSessionToRevoke && void revokeAuthSession(authSessionToRevoke)}
859
+ />
860
+ <ConfirmDialog
861
+ open={confirmKillServer}
862
+ title="Kill server?"
863
+ message="Are you sure you want to kill the server?"
864
+ confirmLabel="Kill server"
865
+ danger
866
+ onCancel={() => setConfirmKillServer(false)}
867
+ onConfirm={() => void killServer().catch((err) => setError(String(err)))}
868
+ />
768
869
  </div>
769
870
  );
770
871
  }
@@ -776,10 +877,11 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
776
877
  const [search, setSearch] = useState("");
777
878
  const [sidebarOpen, setSidebarOpen] = useState(false);
778
879
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
880
+ const sidebarSearchEnabled = sidebar.search !== false;
779
881
  const pinningEnabled = sidebar.pinning !== false;
780
882
  const sidebarPins = useSidebarPins(appName, sidebar.pinning ? sidebar.pinning.storageKey : undefined);
781
883
  const baseNodes = useMemo(() => sidebar.getNodes({ search: "" }), [sidebar]);
782
- const filteredNodes = useMemo(() => sidebar.getNodes({ search }), [sidebar, search]);
884
+ const filteredNodes = useMemo(() => sidebar.getNodes({ search: sidebarSearchEnabled ? search : "" }), [sidebar, search, sidebarSearchEnabled]);
783
885
  const allPinnableItems = useMemo(() => flattenSidebarItems(baseNodes).filter((node) => node.pinnable && node.route), [baseNodes]);
784
886
  const currentPins = useMemo(() => {
785
887
  const byId = new Map(allPinnableItems.map((node) => [node.pinId ?? node.id, node]));
@@ -807,7 +909,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
807
909
  }), [pinningActionFor]);
808
910
  const nodes = useMemo(() => {
809
911
  const augmented = augmentPinningActions(filteredNodes);
810
- if (!pinningEnabled || search.trim() || currentPins.length === 0) return augmented;
912
+ if (!pinningEnabled || (sidebarSearchEnabled && search.trim()) || currentPins.length === 0) return augmented;
811
913
  const augmentedByPinId = new Map(flattenSidebarItems(augmentPinningActions(baseNodes)).map((node) => [node.pinId ?? node.id, node]));
812
914
  const pinnedChildren = currentPins.map((pin) => ({
813
915
  ...(augmentedByPinId.get(pin.id) ?? {
@@ -827,7 +929,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
827
929
  { type: "section" as const, id: "framework:pinned", title: sidebar.pinning ? sidebar.pinning.sectionTitle ?? "Pinned" : "Pinned", children: pinnedChildren },
828
930
  ...augmented,
829
931
  ];
830
- }, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, search, sidebar.pinning]);
932
+ }, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, search, sidebar.pinning, sidebarSearchEnabled]);
831
933
 
832
934
  useEffect(() => {
833
935
  if (!config?.currentUser) return;
@@ -888,7 +990,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
888
990
  </div>
889
991
  </div>
890
992
  <div className="wapp-sidebar-scroll">
891
- <label className="wapp-search"><span className="sr-only">Search</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search" /></label>
993
+ {sidebarSearchEnabled ? <label className="wapp-search"><span className="sr-only">Search</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search" /></label> : null}
892
994
  <SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} />
893
995
  </div>
894
996
  <div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
@@ -0,0 +1,64 @@
1
+ export class WebAppApiError extends Error {
2
+ status: number;
3
+ error?: string;
4
+ details?: unknown;
5
+
6
+ constructor(message: string, status: number, error?: string, details?: unknown) {
7
+ super(message);
8
+ this.name = "WebAppApiError";
9
+ this.status = status;
10
+ this.error = error;
11
+ this.details = details;
12
+ }
13
+ }
14
+
15
+ export type AuthRequiredListener = () => void;
16
+
17
+ const authRequiredListeners = new Set<AuthRequiredListener>();
18
+
19
+ export function onAuthRequired(listener: AuthRequiredListener): () => void {
20
+ authRequiredListeners.add(listener);
21
+ return () => authRequiredListeners.delete(listener);
22
+ }
23
+
24
+ function emitAuthRequired(): void {
25
+ for (const listener of authRequiredListeners) {
26
+ listener();
27
+ }
28
+ }
29
+
30
+ export function appPath(path: string): string {
31
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) return path;
32
+ const base = document.querySelector("base")?.getAttribute("href") ?? "/";
33
+ return new URL(path.replace(/^\/+/, ""), new URL(base, window.location.href)).toString();
34
+ }
35
+
36
+ export function appWebSocketUrl(path = "/api/ws"): string {
37
+ const url = new URL(appPath(path));
38
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
39
+ return url.toString();
40
+ }
41
+
42
+ export async function appFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
43
+ const response = await fetch(typeof input === "string" ? appPath(input) : input, {
44
+ ...init,
45
+ credentials: init?.credentials ?? "same-origin",
46
+ headers: {
47
+ accept: "application/json",
48
+ ...init?.headers,
49
+ },
50
+ });
51
+ if (response.headers.get("x-webapp-passkey-required") === "true" || response.headers.get("x-passkey-auth-required") === "true") {
52
+ emitAuthRequired();
53
+ }
54
+ if (!response.ok) {
55
+ let body: { error?: string; message?: string; details?: unknown } | undefined;
56
+ try {
57
+ body = await response.clone().json() as typeof body;
58
+ } catch {
59
+ body = undefined;
60
+ }
61
+ throw new WebAppApiError(body?.message ?? `Request failed with status ${response.status}`, response.status, body?.error, body?.details);
62
+ }
63
+ return response;
64
+ }
package/src/web/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./WebAppRoot";
2
2
  export * from "./components";
3
3
  export * from "./render";
4
+ export * from "./api-client";
4
5
  export * from "./sidebar/types";
5
6
  export * from "./realtime/useRealtime";
6
7
  export type { ThemePreference } from "../contracts";
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { appWebSocketUrl } from "../api-client";
2
3
 
3
4
  export type RealtimeStatus = "connecting" | "open" | "closed";
4
5
  export type RealtimeAction = "created" | "updated" | "changed" | "deleted";
@@ -71,8 +72,7 @@ export function useRealtime<TEvent>({
71
72
  for (const [key, value] of Object.entries(filters ?? {})) {
72
73
  if (value) params.set(key, value);
73
74
  }
74
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
75
- socket = new WebSocket(`${protocol}//${window.location.host}${path}${params.size ? `?${params.toString()}` : ""}`);
75
+ socket = new WebSocket(appWebSocketUrl(`${path}${params.size ? `?${params.toString()}` : ""}`));
76
76
  setStatus("connecting");
77
77
  socket.onopen = () => {
78
78
  retryRef.current = 0;
@@ -725,6 +725,7 @@ textarea::placeholder {
725
725
  font-size: 0.875rem;
726
726
  font-weight: 600;
727
727
  padding: 0.5rem 0.875rem;
728
+ white-space: nowrap;
728
729
  transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
729
730
  }
730
731
 
@@ -978,6 +979,10 @@ textarea::placeholder {
978
979
  gap: 0.5rem;
979
980
  }
980
981
 
982
+ .wapp-settings .wapp-row-actions {
983
+ flex: 0 0 auto;
984
+ }
985
+
981
986
  .wapp-danger-zone {
982
987
  display: flex;
983
988
  align-items: flex-start;
@@ -1064,6 +1069,45 @@ textarea::placeholder {
1064
1069
  color: var(--wapp-muted);
1065
1070
  }
1066
1071
 
1072
+ .wapp-shutdown-countdown {
1073
+ display: grid;
1074
+ gap: 0.5rem;
1075
+ margin-top: 0.75rem;
1076
+ font-weight: 600;
1077
+ }
1078
+
1079
+ .wapp-shutdown-message {
1080
+ color: #dc2626;
1081
+ font-size: 0.875rem;
1082
+ }
1083
+
1084
+ :root.dark .wapp-shutdown-message {
1085
+ color: #f87171;
1086
+ }
1087
+
1088
+ .wapp-shutdown-progress {
1089
+ width: 100%;
1090
+ height: 0.375rem;
1091
+ overflow: hidden;
1092
+ border-radius: 999px;
1093
+ background: #fecaca;
1094
+ }
1095
+
1096
+ :root.dark .wapp-shutdown-progress {
1097
+ background: #7f1d1d;
1098
+ }
1099
+
1100
+ .wapp-shutdown-progress-bar {
1101
+ height: 100%;
1102
+ border-radius: inherit;
1103
+ background: #ef4444;
1104
+ transition: width 1s linear;
1105
+ }
1106
+
1107
+ :root.dark .wapp-shutdown-progress-bar {
1108
+ background: #f87171;
1109
+ }
1110
+
1067
1111
  .wapp-segmented {
1068
1112
  display: flex;
1069
1113
  width: 100%;
@@ -1117,6 +1161,10 @@ textarea::placeholder {
1117
1161
  padding: 0.75rem;
1118
1162
  }
1119
1163
 
1164
+ .wapp-list-row > span {
1165
+ min-width: 0;
1166
+ }
1167
+
1120
1168
  .wapp-grid-2 {
1121
1169
  display: grid;
1122
1170
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1356,3 +1404,21 @@ textarea::placeholder {
1356
1404
  margin-top: 0.75rem;
1357
1405
  }
1358
1406
  }
1407
+
1408
+ @media (max-width: 640px) {
1409
+ .wapp-settings .wapp-list-row {
1410
+ display: grid;
1411
+ grid-template-columns: minmax(0, 1fr);
1412
+ align-items: start;
1413
+ gap: 0.75rem;
1414
+ }
1415
+
1416
+ .wapp-settings .wapp-list-row .wapp-row-actions {
1417
+ justify-content: flex-start;
1418
+ margin-top: 0;
1419
+ }
1420
+
1421
+ .wapp-settings .wapp-row-actions .wapp-inline-select {
1422
+ max-width: 100%;
1423
+ }
1424
+ }