@pablozaiden/webapp 0.5.8 → 0.6.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.
- package/README.md +12 -4
- package/docs/auth-validation.md +11 -0
- package/docs/auth.md +33 -0
- package/docs/cli.md +73 -3
- package/docs/deployment.md +84 -7
- package/docs/getting-started.md +69 -6
- package/docs/github-actions.md +39 -0
- package/docs/release.md +5 -5
- package/docs/server.md +34 -4
- package/docs/settings.md +5 -0
- package/docs/ui-guidelines.md +12 -4
- package/package.json +2 -4
- package/src/build/build-binary.ts +67 -24
- package/src/cli/api-command.ts +53 -16
- package/src/cli/credentials.ts +275 -4
- package/src/cli/device-auth.ts +83 -22
- package/src/cli/environment-auth.ts +57 -0
- package/src/cli/index.ts +1 -0
- package/src/package-resolution.ts +34 -0
- package/src/server/auth/device-auth.ts +2 -2
- package/src/server/auth/passkeys.ts +16 -16
- package/src/server/auth/request-origin.ts +97 -16
- package/src/server/authentication.ts +265 -0
- package/src/server/create-web-app-server.ts +85 -1391
- package/src/server/framework-endpoints.ts +451 -0
- package/src/server/index.ts +1 -0
- package/src/server/public-route-dispatch.ts +85 -0
- package/src/server/request-schemas.ts +144 -0
- package/src/server/responses.ts +69 -3
- package/src/server/route-dispatch.ts +109 -0
- package/src/server/runtime-config.ts +93 -1
- package/src/server/same-origin.ts +1 -1
- package/src/server/server-lifecycle.ts +138 -0
- package/src/server/server-types.ts +87 -0
- package/src/server/web-document.ts +532 -0
- package/src/web/WebAppRoot.tsx +69 -1214
- package/src/web/api-client.ts +14 -4
- package/src/web/app-shell.tsx +198 -0
- package/src/web/auth-screens.tsx +186 -0
- package/src/web/components/index.tsx +10 -3
- package/src/web/index.ts +1 -0
- package/src/web/mobile-hooks.ts +194 -0
- package/src/web/mobile.ts +12 -0
- package/src/web/root-types.ts +61 -0
- package/src/web/routing.ts +61 -0
- package/src/web/settings/account-section.tsx +52 -0
- package/src/web/settings/resource-state.tsx +17 -0
- package/src/web/settings/security-section.tsx +119 -0
- package/src/web/settings/sessions-section.tsx +66 -0
- package/src/web/settings/settings-view.tsx +96 -0
- package/src/web/settings/shutdown-section.tsx +95 -0
- package/src/web/settings/user-management.tsx +123 -0
- package/src/web/settings-view.tsx +3 -0
- package/src/web/sidebar-state.ts +108 -0
- package/src/web/sidebar-tree.tsx +89 -0
- package/src/web/styles.css +76 -64
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ActionMenuItem, SidebarAction, SidebarBuildContext, SidebarNode, WebAppRoute } from "./sidebar/types";
|
|
3
|
+
|
|
4
|
+
export type SettingsScope = "user" | "admin" | "owner";
|
|
5
|
+
|
|
6
|
+
export type SettingsAction = {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
variant?: "default" | "primary" | "danger" | "ghost";
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
onAction: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SettingsRow = {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
scope?: SettingsScope;
|
|
19
|
+
content?: ReactNode;
|
|
20
|
+
actions?: ReactNode | SettingsAction[];
|
|
21
|
+
danger?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SettingsSection = {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
scope?: SettingsScope;
|
|
29
|
+
rows?: SettingsRow[];
|
|
30
|
+
render?: () => ReactNode;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type HeaderContext = {
|
|
34
|
+
route: WebAppRoute;
|
|
35
|
+
defaultTitle: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface WebAppRootProps {
|
|
39
|
+
appName: string;
|
|
40
|
+
homeRoute: WebAppRoute;
|
|
41
|
+
sidebar: {
|
|
42
|
+
search?: boolean;
|
|
43
|
+
topActions?: SidebarAction[];
|
|
44
|
+
pinning?: false | {
|
|
45
|
+
sectionTitle?: string;
|
|
46
|
+
storageKey?: string;
|
|
47
|
+
};
|
|
48
|
+
getNodes: (ctx: SidebarBuildContext) => SidebarNode[];
|
|
49
|
+
};
|
|
50
|
+
routes: Record<string, ReactNode | ((route: WebAppRoute) => ReactNode)>;
|
|
51
|
+
header?: {
|
|
52
|
+
renderTitle?: (ctx: HeaderContext) => ReactNode;
|
|
53
|
+
renderActions?: (ctx: HeaderContext) => ReactNode;
|
|
54
|
+
getActions?: (ctx: HeaderContext) => ActionMenuItem[];
|
|
55
|
+
};
|
|
56
|
+
onRouteChange?: (route: WebAppRoute) => void;
|
|
57
|
+
settings?: {
|
|
58
|
+
sections?: SettingsSection[];
|
|
59
|
+
};
|
|
60
|
+
version?: string;
|
|
61
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import type { WebAppRoute } from "./sidebar/types";
|
|
3
|
+
|
|
4
|
+
export function routeToHash(route: WebAppRoute): string {
|
|
5
|
+
const params = new URLSearchParams();
|
|
6
|
+
for (const [key, value] of Object.entries(route)) {
|
|
7
|
+
if (key !== "view" && value !== undefined) {
|
|
8
|
+
params.set(key, String(value));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return `#/${route.view}${params.size ? `?${params.toString()}` : ""}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function replaceHashRoute(hash: string): boolean {
|
|
15
|
+
const normalizedHash = hash.startsWith("#") ? hash : `#${hash}`;
|
|
16
|
+
if (window.location.hash === normalizedHash) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const previousUrl = window.location.href;
|
|
21
|
+
let hashChangeEmitted = false;
|
|
22
|
+
const markHashChangeEmitted = () => {
|
|
23
|
+
hashChangeEmitted = true;
|
|
24
|
+
};
|
|
25
|
+
window.addEventListener("hashchange", markHashChangeEmitted, { once: true });
|
|
26
|
+
window.history.replaceState(window.history.state, "", normalizedHash);
|
|
27
|
+
window.removeEventListener("hashchange", markHashChangeEmitted);
|
|
28
|
+
if (!hashChangeEmitted) {
|
|
29
|
+
window.dispatchEvent(new HashChangeEvent("hashchange", { oldURL: previousUrl, newURL: window.location.href }));
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function replaceWebAppRoute(route: WebAppRoute): boolean {
|
|
35
|
+
return replaceHashRoute(routeToHash(route));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseRoute(defaultRoute: WebAppRoute): WebAppRoute {
|
|
39
|
+
const hash = window.location.hash.replace(/^#\/?/, "");
|
|
40
|
+
if (!hash) {
|
|
41
|
+
return defaultRoute;
|
|
42
|
+
}
|
|
43
|
+
const [view = defaultRoute.view, query = ""] = hash.split("?", 2);
|
|
44
|
+
const params = Object.fromEntries(new URLSearchParams(query).entries());
|
|
45
|
+
return { view: view.replace(/^\//, ""), ...params };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useRoute(defaultRoute: WebAppRoute) {
|
|
49
|
+
const [route, setRoute] = useState(() => parseRoute(defaultRoute));
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const listener = () => setRoute(parseRoute(defaultRoute));
|
|
52
|
+
window.addEventListener("hashchange", listener);
|
|
53
|
+
return () => window.removeEventListener("hashchange", listener);
|
|
54
|
+
}, [defaultRoute]);
|
|
55
|
+
const navigate = useCallback((next: WebAppRoute) => {
|
|
56
|
+
if (replaceWebAppRoute(next)) {
|
|
57
|
+
setRoute(next);
|
|
58
|
+
}
|
|
59
|
+
}, []);
|
|
60
|
+
return { route, navigate };
|
|
61
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ThemePreference, WebAppConfigResponse } from "../../contracts";
|
|
2
|
+
import { appJson } from "../api-client";
|
|
3
|
+
import { Badge, Button, ErrorState, FormSection, LoadingState, SelectField } from "../components";
|
|
4
|
+
|
|
5
|
+
export interface AccountSectionProps {
|
|
6
|
+
config: WebAppConfigResponse;
|
|
7
|
+
theme: ThemePreference;
|
|
8
|
+
setTheme: (theme: ThemePreference) => void;
|
|
9
|
+
themeLoading: boolean;
|
|
10
|
+
themeLoadError?: Error;
|
|
11
|
+
retryThemeLoad: () => Promise<void>;
|
|
12
|
+
refresh: () => Promise<void>;
|
|
13
|
+
setError: (error: string | undefined) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function AccountSection({ config, theme, setTheme, themeLoading, themeLoadError, retryThemeLoad, refresh, setError }: AccountSectionProps) {
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
{config.currentUser ? (
|
|
20
|
+
<FormSection title="Account">
|
|
21
|
+
<p>Signed in as <strong>{config.currentUser.username}</strong> <Badge variant={config.currentUser.isAdmin ? "success" : "disabled"}>{config.currentUser.role}</Badge></p>
|
|
22
|
+
</FormSection>
|
|
23
|
+
) : null}
|
|
24
|
+
<FormSection title="Display Settings">
|
|
25
|
+
<SelectField label="Theme" value={theme} onChange={(event) => {
|
|
26
|
+
const next = event.currentTarget.value as ThemePreference;
|
|
27
|
+
setTheme(next);
|
|
28
|
+
void appJson("/api/preferences/theme", { method: "PUT", body: JSON.stringify({ theme: next }) }).catch((err) => setError(String(err)));
|
|
29
|
+
}}>
|
|
30
|
+
<option value="system">System</option>
|
|
31
|
+
<option value="light">Light</option>
|
|
32
|
+
<option value="dark">Dark</option>
|
|
33
|
+
</SelectField>
|
|
34
|
+
{themeLoading ? <LoadingState title="Loading saved theme" /> : null}
|
|
35
|
+
{themeLoadError ? (
|
|
36
|
+
<ErrorState
|
|
37
|
+
description={themeLoadError.message}
|
|
38
|
+
action={<Button type="button" loading={themeLoading} onClick={() => void retryThemeLoad()}>Retry</Button>}
|
|
39
|
+
/>
|
|
40
|
+
) : null}
|
|
41
|
+
</FormSection>
|
|
42
|
+
|
|
43
|
+
{config.currentUser?.isAdmin ? (
|
|
44
|
+
<FormSection title="Developer Settings">
|
|
45
|
+
<SelectField label={config.logLevel.fromEnv ? `Log level (${config.logLevel.level}, controlled by env)` : "Log level"} value={config.logLevel.level} disabled={config.logLevel.fromEnv} onChange={(event) => void appJson("/api/preferences/log-level", { method: "PUT", body: JSON.stringify({ level: event.currentTarget.value }) }).then(refresh)}>
|
|
46
|
+
{["trace", "debug", "info", "warn", "error"].map((level) => <option key={level} value={level}>{level}</option>)}
|
|
47
|
+
</SelectField>
|
|
48
|
+
</FormSection>
|
|
49
|
+
) : null}
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Button, ErrorState, LoadingState } from "../components";
|
|
3
|
+
|
|
4
|
+
export function ResourceState({ loading, error, hasData, refresh }: { loading: boolean; error?: Error; hasData: boolean; refresh: () => Promise<void> }): ReactNode {
|
|
5
|
+
if (!hasData && loading) {
|
|
6
|
+
return <LoadingState />;
|
|
7
|
+
}
|
|
8
|
+
if (!error) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
return (
|
|
12
|
+
<ErrorState
|
|
13
|
+
description={error.message}
|
|
14
|
+
action={<Button type="button" loading={loading} onClick={() => void refresh()}>Retry</Button>}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { startRegistration } from "@simplewebauthn/browser";
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
|
+
import type { ApiKeySummary, CreatedApiKeyResponse, WebAppConfigResponse } from "../../contracts";
|
|
4
|
+
import { appJson } from "../api-client";
|
|
5
|
+
import { Button, ConfirmDialog, EmptyState } from "../components";
|
|
6
|
+
import { useLiveQuery } from "../realtime/useRealtime";
|
|
7
|
+
import { ResourceState } from "./resource-state";
|
|
8
|
+
|
|
9
|
+
export interface SecuritySectionProps {
|
|
10
|
+
config: WebAppConfigResponse;
|
|
11
|
+
refresh: () => Promise<void>;
|
|
12
|
+
setError: (error: string | undefined) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function SecuritySection({ config, refresh, setError }: SecuritySectionProps) {
|
|
16
|
+
const [createdToken, setCreatedToken] = useState<string>();
|
|
17
|
+
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeySummary>();
|
|
18
|
+
const [confirmDeletePasskey, setConfirmDeletePasskey] = useState(false);
|
|
19
|
+
|
|
20
|
+
const loadApiKeys = useCallback(
|
|
21
|
+
() => config.apiKeys.enabled
|
|
22
|
+
? appJson<ApiKeySummary[]>("/api/api-keys")
|
|
23
|
+
: Promise.resolve<ApiKeySummary[]>([]),
|
|
24
|
+
[config.apiKeys.enabled],
|
|
25
|
+
);
|
|
26
|
+
const { data: apiKeys, error: apiKeysLoadError, loading: apiKeysLoading, refresh: refreshApiKeys } = useLiveQuery<ApiKeySummary[]>({ load: loadApiKeys, realtime: false });
|
|
27
|
+
|
|
28
|
+
async function createKey() {
|
|
29
|
+
const result = await appJson<CreatedApiKeyResponse>("/api/api-keys", { method: "POST", body: JSON.stringify({ name: "Browser key", scopes: ["*"] }) });
|
|
30
|
+
setCreatedToken(result.token);
|
|
31
|
+
await refreshApiKeys();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function deleteKey(id: string) {
|
|
35
|
+
try {
|
|
36
|
+
setError(undefined);
|
|
37
|
+
await appJson(`/api/api-keys/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
38
|
+
setApiKeyToDelete(undefined);
|
|
39
|
+
await refreshApiKeys();
|
|
40
|
+
} catch (err) {
|
|
41
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function setupPasskey() {
|
|
46
|
+
try {
|
|
47
|
+
setError(undefined);
|
|
48
|
+
const options = await appJson<PublicKeyCredentialCreationOptionsJSON>("/api/passkey-auth/owner-setup/options", { method: "POST", body: "{}" });
|
|
49
|
+
const credential = await startRegistration({ optionsJSON: options as never });
|
|
50
|
+
await appJson("/api/passkey-auth/owner-setup/verify", { method: "POST", body: JSON.stringify(credential) });
|
|
51
|
+
await refresh();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function logout() {
|
|
58
|
+
await appJson("/api/passkey-auth/logout", { method: "POST", body: "{}" });
|
|
59
|
+
await refresh();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function deleteConfiguredPasskey() {
|
|
63
|
+
await appJson("/api/passkey-auth/passkey", { method: "DELETE" });
|
|
64
|
+
setConfirmDeletePasskey(false);
|
|
65
|
+
await refresh();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
<div className="wapp-settings-row">
|
|
71
|
+
<div>
|
|
72
|
+
<strong>Passkey</strong>
|
|
73
|
+
<p>{config.passkeyAuth.passkeyConfigured ? "Your passkey protects this account." : "No passkey configured yet."}</p>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="wapp-row-actions">
|
|
76
|
+
{config.passkeyAuth.passkeyConfigured ? (
|
|
77
|
+
<>
|
|
78
|
+
<Button type="button" onClick={() => void logout()}>Logout</Button>
|
|
79
|
+
<Button type="button" variant="danger" onClick={() => setConfirmDeletePasskey(true)}>Delete passkey</Button>
|
|
80
|
+
</>
|
|
81
|
+
) : (
|
|
82
|
+
config.currentUser?.isOwner ? <Button type="button" variant="primary" onClick={() => void setupPasskey()}>Set up passkey</Button> : null
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
{config.apiKeys.enabled ? (
|
|
87
|
+
<div className="wapp-settings-row stacked">
|
|
88
|
+
<div>
|
|
89
|
+
<strong>API keys</strong>
|
|
90
|
+
<p>Create bearer tokens for scripts and agents.</p>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="wapp-row-actions"><Button type="button" onClick={() => void createKey().catch((err) => setError(String(err)))}>Create API key</Button></div>
|
|
93
|
+
{createdToken ? <code className="wapp-token">{createdToken}</code> : null}
|
|
94
|
+
<ResourceState loading={apiKeysLoading} error={apiKeysLoadError} hasData={apiKeys !== undefined} refresh={refreshApiKeys} />
|
|
95
|
+
{apiKeys?.length ? (
|
|
96
|
+
<div className="wapp-list">
|
|
97
|
+
{apiKeys.map((key) => (
|
|
98
|
+
<div className="wapp-list-row" key={key.id}>
|
|
99
|
+
<span><strong>{key.name}</strong><small>{key.scopes.join(", ")} · {key.createdAt}</small></span>
|
|
100
|
+
<Button type="button" variant="danger" onClick={() => setApiKeyToDelete(key)}>Delete</Button>
|
|
101
|
+
</div>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
) : apiKeys !== undefined && !apiKeysLoadError ? <EmptyState title="No API keys" /> : null}
|
|
105
|
+
</div>
|
|
106
|
+
) : null}
|
|
107
|
+
<ConfirmDialog
|
|
108
|
+
open={Boolean(apiKeyToDelete)}
|
|
109
|
+
title="Delete API key?"
|
|
110
|
+
message={apiKeyToDelete ? `This permanently deletes "${apiKeyToDelete.name}". Scripts and agents using this token will stop working.` : ""}
|
|
111
|
+
confirmLabel="Delete API key"
|
|
112
|
+
danger
|
|
113
|
+
onCancel={() => setApiKeyToDelete(undefined)}
|
|
114
|
+
onConfirm={() => apiKeyToDelete && void deleteKey(apiKeyToDelete.id)}
|
|
115
|
+
/>
|
|
116
|
+
<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()} />
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import type { AuthSessionSummary, WebAppConfigResponse } from "../../contracts";
|
|
3
|
+
import { appJson } from "../api-client";
|
|
4
|
+
import { Button, ConfirmDialog, EmptyState } from "../components";
|
|
5
|
+
import { useLiveQuery } from "../realtime/useRealtime";
|
|
6
|
+
import { ResourceState } from "./resource-state";
|
|
7
|
+
|
|
8
|
+
export interface SessionsSectionProps {
|
|
9
|
+
config: WebAppConfigResponse;
|
|
10
|
+
setError: (error: string | undefined) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SessionsSection({ config, setError }: SessionsSectionProps) {
|
|
14
|
+
const [authSessionToRevoke, setAuthSessionToRevoke] = useState<AuthSessionSummary>();
|
|
15
|
+
|
|
16
|
+
const loadAuthSessions = useCallback(
|
|
17
|
+
() => config.deviceAuth.enabled
|
|
18
|
+
? appJson<AuthSessionSummary[]>("/api/auth/sessions")
|
|
19
|
+
: Promise.resolve<AuthSessionSummary[]>([]),
|
|
20
|
+
[config.deviceAuth.enabled],
|
|
21
|
+
);
|
|
22
|
+
const { data: authSessions, error: authSessionsLoadError, loading: authSessionsLoading, refresh: refreshAuthSessions } = useLiveQuery<AuthSessionSummary[]>({ load: loadAuthSessions, realtime: false });
|
|
23
|
+
|
|
24
|
+
async function revokeAuthSession(session: AuthSessionSummary) {
|
|
25
|
+
try {
|
|
26
|
+
setError(undefined);
|
|
27
|
+
await appJson(`/api/auth/sessions/${session.id}`, { method: "DELETE" });
|
|
28
|
+
setAuthSessionToRevoke(undefined);
|
|
29
|
+
await refreshAuthSessions();
|
|
30
|
+
} catch (err) {
|
|
31
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!config.deviceAuth.enabled) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
<div className="wapp-settings-row stacked">
|
|
41
|
+
<div>
|
|
42
|
+
<strong>Device auth sessions</strong>
|
|
43
|
+
<p>Refresh-token sessions created through the device flow.</p>
|
|
44
|
+
</div>
|
|
45
|
+
<ResourceState loading={authSessionsLoading} error={authSessionsLoadError} hasData={authSessions !== undefined} refresh={refreshAuthSessions} />
|
|
46
|
+
<div className="wapp-list">
|
|
47
|
+
{authSessions?.length ? authSessions.map((session) => (
|
|
48
|
+
<div className="wapp-list-row" key={session.id}>
|
|
49
|
+
<span><strong>{session.clientId}</strong><small>{session.scope} · {session.updatedAt}</small></span>
|
|
50
|
+
<Button type="button" variant="danger" onClick={() => setAuthSessionToRevoke(session)}>Revoke</Button>
|
|
51
|
+
</div>
|
|
52
|
+
)) : authSessions !== undefined && !authSessionsLoadError ? <EmptyState title="No device sessions" /> : null}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
<ConfirmDialog
|
|
56
|
+
open={Boolean(authSessionToRevoke)}
|
|
57
|
+
title="Revoke device session?"
|
|
58
|
+
message={authSessionToRevoke ? `This revokes the active "${authSessionToRevoke.clientId}" refresh-token session.` : ""}
|
|
59
|
+
confirmLabel="Revoke session"
|
|
60
|
+
danger
|
|
61
|
+
onCancel={() => setAuthSessionToRevoke(undefined)}
|
|
62
|
+
onConfirm={() => authSessionToRevoke && void revokeAuthSession(authSessionToRevoke)}
|
|
63
|
+
/>
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import type { ThemePreference, WebAppConfigResponse } from "../../contracts";
|
|
3
|
+
import { Button, DangerZone, FormSection } from "../components";
|
|
4
|
+
import type { SettingsRow, SettingsScope, SettingsSection } from "../root-types";
|
|
5
|
+
import { AccountSection } from "./account-section";
|
|
6
|
+
import { SecuritySection } from "./security-section";
|
|
7
|
+
import { SessionsSection } from "./sessions-section";
|
|
8
|
+
import { ShutdownSection } from "./shutdown-section";
|
|
9
|
+
import { UserManagement } from "./user-management";
|
|
10
|
+
|
|
11
|
+
export type { SettingsAction, SettingsRow, SettingsSection } from "../root-types";
|
|
12
|
+
|
|
13
|
+
function renderSettingsActions(actions: SettingsRow["actions"]): ReactNode {
|
|
14
|
+
if (!actions) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (!Array.isArray(actions)) {
|
|
18
|
+
return actions;
|
|
19
|
+
}
|
|
20
|
+
return actions.map((action) => (
|
|
21
|
+
<Button key={action.id} type="button" variant={action.variant} disabled={action.disabled} onClick={action.onAction}>{action.label}</Button>
|
|
22
|
+
));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function StructuredSettingsSection({ section }: { section: SettingsSection }) {
|
|
26
|
+
if (!section.rows?.length && section.render) {
|
|
27
|
+
return <section className="wapp-custom-settings-section">{section.render()}</section>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<FormSection title={section.title} description={section.description}>
|
|
32
|
+
{section.rows?.map((row) => {
|
|
33
|
+
const actions = renderSettingsActions(row.actions);
|
|
34
|
+
if (row.danger) {
|
|
35
|
+
return <DangerZone key={row.id} title={row.title} description={row.description} actions={actions} />;
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
<div className="wapp-settings-row" key={row.id}>
|
|
39
|
+
<div>
|
|
40
|
+
<strong>{row.title}</strong>
|
|
41
|
+
{row.description ? <p>{row.description}</p> : null}
|
|
42
|
+
{row.content ? <div className="wapp-settings-row-content">{row.content}</div> : null}
|
|
43
|
+
</div>
|
|
44
|
+
{actions ? <div className="wapp-row-actions">{actions}</div> : null}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
})}
|
|
48
|
+
{section.render?.()}
|
|
49
|
+
</FormSection>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isScopeVisible(scope: SettingsScope | undefined, config: WebAppConfigResponse): boolean {
|
|
54
|
+
if (!scope || scope === "user") {
|
|
55
|
+
return Boolean(config.currentUser);
|
|
56
|
+
}
|
|
57
|
+
if (scope === "admin") {
|
|
58
|
+
return Boolean(config.currentUser?.isAdmin);
|
|
59
|
+
}
|
|
60
|
+
return Boolean(config.currentUser?.isOwner);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SettingsViewProps {
|
|
64
|
+
config: WebAppConfigResponse;
|
|
65
|
+
refresh: () => Promise<void>;
|
|
66
|
+
customSections: SettingsSection[];
|
|
67
|
+
theme: ThemePreference;
|
|
68
|
+
setTheme: (theme: ThemePreference) => void;
|
|
69
|
+
themeLoading: boolean;
|
|
70
|
+
themeLoadError?: Error;
|
|
71
|
+
retryThemeLoad: () => Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function SettingsView({ config, refresh, customSections, theme, setTheme, themeLoading, themeLoadError, retryThemeLoad }: SettingsViewProps) {
|
|
75
|
+
const [error, setError] = useState<string>();
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="wapp-settings">
|
|
79
|
+
{error ? <p className="wapp-error">{error}</p> : null}
|
|
80
|
+
<AccountSection config={config} theme={theme} setTheme={setTheme} themeLoading={themeLoading} themeLoadError={themeLoadError} retryThemeLoad={retryThemeLoad} refresh={refresh} setError={setError} />
|
|
81
|
+
<FormSection title="Security">
|
|
82
|
+
<SecuritySection config={config} refresh={refresh} setError={setError} />
|
|
83
|
+
<SessionsSection config={config} setError={setError} />
|
|
84
|
+
</FormSection>
|
|
85
|
+
<UserManagement config={config} />
|
|
86
|
+
<ShutdownSection config={config} setError={setError} />
|
|
87
|
+
{customSections.filter((section) => isScopeVisible(section.scope, config)).map((section) => ({
|
|
88
|
+
...section,
|
|
89
|
+
rows: section.rows?.filter((row) => isScopeVisible(row.scope, config)),
|
|
90
|
+
})).map((section) => <StructuredSettingsSection key={section.id} section={section} />)}
|
|
91
|
+
<FormSection title="About">
|
|
92
|
+
<p>{config.appName} {config.version}</p>
|
|
93
|
+
</FormSection>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import type { WebAppConfigResponse } from "../../contracts";
|
|
3
|
+
import { appFetch } from "../api-client";
|
|
4
|
+
import { Button, ConfirmDialog, FormSection } from "../components";
|
|
5
|
+
|
|
6
|
+
const KILL_SERVER_COUNTDOWN_SECONDS = 15;
|
|
7
|
+
|
|
8
|
+
function computeProgressPercent(countdown: number, total: number): number {
|
|
9
|
+
return total <= 0 ? 0 : (countdown / total) * 100;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function useCountdownReload(active: boolean, onComplete: () => void, durationSeconds = KILL_SERVER_COUNTDOWN_SECONDS): { countdown: number; progressPercent: number } {
|
|
13
|
+
const [countdown, setCountdown] = useState(durationSeconds);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!active) {
|
|
17
|
+
setCountdown(durationSeconds);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
setCountdown(durationSeconds);
|
|
22
|
+
const interval = window.setInterval(() => {
|
|
23
|
+
setCountdown((previous) => {
|
|
24
|
+
const next = previous - 1;
|
|
25
|
+
if (next <= 0) {
|
|
26
|
+
window.clearInterval(interval);
|
|
27
|
+
onComplete();
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
return next;
|
|
31
|
+
});
|
|
32
|
+
}, 1000);
|
|
33
|
+
|
|
34
|
+
return () => window.clearInterval(interval);
|
|
35
|
+
}, [active, durationSeconds, onComplete]);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
countdown,
|
|
39
|
+
progressPercent: computeProgressPercent(countdown, durationSeconds),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ShutdownSection({ config, setError }: { config: WebAppConfigResponse; setError: (error: string | undefined) => void }) {
|
|
44
|
+
const [confirmKillServer, setConfirmKillServer] = useState(false);
|
|
45
|
+
const [killRequested, setKillRequested] = useState(false);
|
|
46
|
+
const reloadPage = useCallback(() => window.location.reload(), []);
|
|
47
|
+
const { countdown, progressPercent } = useCountdownReload(killRequested, reloadPage);
|
|
48
|
+
|
|
49
|
+
async function killServer() {
|
|
50
|
+
try {
|
|
51
|
+
setError(undefined);
|
|
52
|
+
setConfirmKillServer(false);
|
|
53
|
+
await appFetch("/api/server/kill", { method: "POST" });
|
|
54
|
+
setKillRequested(true);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!config.currentUser?.isAdmin) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<FormSection title="Server operations">
|
|
66
|
+
<div className="wapp-settings-row">
|
|
67
|
+
<div>
|
|
68
|
+
<strong>Kill server</strong>
|
|
69
|
+
<p>Stop the server process. If your deployment restarts it automatically, the app will come back after a moment.</p>
|
|
70
|
+
{killRequested ? (
|
|
71
|
+
<div className="wapp-shutdown-countdown" aria-live="polite">
|
|
72
|
+
<div className="wapp-shutdown-message">Server is shutting down... Reloading in {countdown}s</div>
|
|
73
|
+
<div className="wapp-shutdown-progress" aria-hidden="true">
|
|
74
|
+
<div className="wapp-shutdown-progress-bar" style={{ width: `${progressPercent}%` }} />
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
) : null}
|
|
78
|
+
</div>
|
|
79
|
+
<div className="wapp-row-actions">
|
|
80
|
+
<Button type="button" variant="danger" disabled={killRequested} onClick={() => setConfirmKillServer(true)}>Kill server</Button>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</FormSection>
|
|
84
|
+
<ConfirmDialog
|
|
85
|
+
open={confirmKillServer}
|
|
86
|
+
title="Kill server?"
|
|
87
|
+
message="Are you sure you want to kill the server?"
|
|
88
|
+
confirmLabel="Kill server"
|
|
89
|
+
danger
|
|
90
|
+
onCancel={() => setConfirmKillServer(false)}
|
|
91
|
+
onConfirm={() => void killServer()}
|
|
92
|
+
/>
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
}
|