@pablozaiden/webapp 0.7.0 → 1.1.0
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 +1 -1
- package/docs/auth-validation.md +4 -0
- package/docs/auth.md +32 -0
- package/docs/server.md +7 -1
- package/docs/settings.md +6 -2
- package/docs/ui-guidelines.md +2 -1
- package/package.json +1 -1
- package/src/contracts/index.ts +2 -0
- package/src/server/auth/api-keys.ts +78 -5
- package/src/server/auth/sqlite-store.ts +36 -4
- package/src/server/auth/store.ts +3 -1
- package/src/server/framework-endpoints.ts +1 -0
- package/src/server/index.ts +1 -0
- package/src/web/components/index.tsx +11 -1
- package/src/web/styles.css +5 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @pablozaiden/webapp
|
|
2
2
|
|
|
3
|
-
Opinionated Bun + React framework for single-server TypeScript webapps: one Bun process serves the React UI, API routes, multi-user passkey auth, user-owned API keys, device auth, stateless CLI API-key auth, realtime websocket state, scoped settings, binary builds and Docker images.
|
|
3
|
+
Opinionated Bun + React framework for single-server TypeScript webapps: one Bun process serves the React UI, API routes, multi-user passkey auth, user-owned and server-managed API keys, device auth, stateless CLI API-key auth, realtime websocket state, scoped settings, binary builds and Docker images.
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
|
package/docs/auth-validation.md
CHANGED
|
@@ -39,6 +39,10 @@ Run this checklist before releasing a framework app or cutting a checkpoint.
|
|
|
39
39
|
3. Call a protected API route with `Authorization: Bearer <token>`.
|
|
40
40
|
4. Delete the key from Settings and confirm the confirmation dialog appears.
|
|
41
41
|
5. Confirm the deleted token no longer authenticates.
|
|
42
|
+
6. Create a managed key through the server-only helper and confirm it authenticates through the same bearer path.
|
|
43
|
+
7. Confirm managed keys and `managedBy` metadata are absent from the normal API-key list and Settings UI.
|
|
44
|
+
8. Restart against the same data directory and confirm a non-expired managed key still authenticates.
|
|
45
|
+
9. Revoke the managed key through the server-only helper and confirm it no longer authenticates.
|
|
42
46
|
|
|
43
47
|
## CLI API-key authentication
|
|
44
48
|
|
package/docs/auth.md
CHANGED
|
@@ -42,6 +42,38 @@ API keys are user-owned bearer tokens for scripts and agents. They are stored ha
|
|
|
42
42
|
|
|
43
43
|
Expired API keys do not authenticate, are omitted from user-facing lists, and are purged when key lists or expired keys are encountered.
|
|
44
44
|
|
|
45
|
+
Server-side applications can create managed keys through the server-only helpers exported from
|
|
46
|
+
`@pablozaiden/webapp/server`:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { createManagedApiKey, listManagedApiKeys, revokeManagedApiKey } from "@pablozaiden/webapp/server";
|
|
50
|
+
|
|
51
|
+
const created = createManagedApiKey(app.store, user, {
|
|
52
|
+
name: "Runtime credential",
|
|
53
|
+
managedBy: "my-application",
|
|
54
|
+
scopes: ["*"],
|
|
55
|
+
});
|
|
56
|
+
const keys = listManagedApiKeys(app.store, user.id, "my-application");
|
|
57
|
+
revokeManagedApiKey(app.store, created.key.id, user.id);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Managed keys have an explicit `kind: "managed"` classification and optional opaque
|
|
61
|
+
`managedBy` metadata. The plaintext token is returned only by creation and is never
|
|
62
|
+
stored; applications must retain it securely. Managed keys use the same bearer
|
|
63
|
+
authentication, ownership, scope, expiration, disabled-user, and lifecycle invalidation
|
|
64
|
+
rules as user keys. They survive an ordinary restart, and applications should explicitly
|
|
65
|
+
revoke each generation during their own resource cleanup.
|
|
66
|
+
|
|
67
|
+
Managed keys have no browser/API CRUD endpoint. The normal `/api/api-keys` routes and
|
|
68
|
+
Settings UI expose only `kind: "user"` keys, and request input cannot create or convert a
|
|
69
|
+
managed key. User reset, passkey deletion, user deletion, and expiration cleanup cover
|
|
70
|
+
both kinds.
|
|
71
|
+
|
|
72
|
+
Custom `WebAppStore` implementations must persist `kind` and `managedBy`, migrate
|
|
73
|
+
pre-managed records as `kind: "user"`, include both kinds in expiration cleanup and
|
|
74
|
+
user deletion/reset cleanup, and preserve the owner filter used by the management
|
|
75
|
+
helpers. The built-in SQLite store performs this migration automatically.
|
|
76
|
+
|
|
45
77
|
Same-origin checks are skipped for API-key and device bearer requests unless a route sets `sameOrigin: "always"`, because non-browser clients usually do not send `Origin`.
|
|
46
78
|
|
|
47
79
|
## Device auth
|
package/docs/server.md
CHANGED
|
@@ -148,7 +148,7 @@ Built-in endpoints include:
|
|
|
148
148
|
| `/api/user-setup*` | One-time invite/reset setup links |
|
|
149
149
|
| `/setup` | Browser setup screen for one-time invite/reset links |
|
|
150
150
|
| `/api/users`, `/api/users/:id/*`, `/api/audit-events` | Admin user management and audit log |
|
|
151
|
-
| `/api/api-keys` | Browser-managed API
|
|
151
|
+
| `/api/api-keys` | Browser-managed user API-key create/list/delete |
|
|
152
152
|
| `/api/auth/device`, `/api/auth/token`, `/api/auth/refresh`, `/api/auth/revoke` | Device auth and refresh-token flow |
|
|
153
153
|
| `/device` | Browser device-code approval screen |
|
|
154
154
|
| `/.well-known/jwks.json`, `/.well-known/openid-configuration` | Token verification metadata |
|
|
@@ -162,6 +162,12 @@ The effective log level in `GET /api/config` and `GET
|
|
|
162
162
|
to `true`. PUT remains an authenticated, same-origin admin mutation and
|
|
163
163
|
returns a conflict when the environment controls the value.
|
|
164
164
|
|
|
165
|
+
Server-side applications that need credentials for internal runtimes should use
|
|
166
|
+
the server-only `createManagedApiKey`, `listManagedApiKeys`, and
|
|
167
|
+
`revokeManagedApiKey` helpers. Managed keys are persisted in the same API-key
|
|
168
|
+
store, authenticate through the normal bearer path, and are intentionally absent
|
|
169
|
+
from the browser-managed endpoint and Settings summaries.
|
|
170
|
+
|
|
165
171
|
## Framework-owned web document and PWA metadata
|
|
166
172
|
|
|
167
173
|
`createWebAppServer` owns the browser document. Apps do not provide `index.html`; the framework generates a Bun `HTMLBundle` internally so Bun hot reload and asset rewriting keep working. By default the frontend entrypoint is `./web/main.tsx` relative to the Bun entry file. The generated document also initializes the shared `data-wapp-mobile` state before client styles load so CSS and `WebAppRoot` use the same mobile breakpoint. The generated viewport keeps the app at `initial-scale=1` with `maximum-scale=1` and `user-scalable=no`; clients and mobile browsers that honor those viewport scaling tokens, including iPhone and iPad, cannot change the app scale with pinch-to-zoom while normal scrolling remains enabled. Clients that ignore the tokens are unaffected.
|
package/docs/settings.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Settings is framework-owned so apps stay consistent. It includes:
|
|
4
4
|
|
|
5
5
|
- Account summary and passkey logout/delete
|
|
6
|
-
- API key create/list/delete for the current user when enabled
|
|
6
|
+
- User API key create/list/delete for the current user when enabled
|
|
7
7
|
- Device-auth sessions for the current user when enabled
|
|
8
8
|
- Theme preference: system/light/dark, stored per user
|
|
9
9
|
- Admin user management
|
|
@@ -33,7 +33,11 @@ the shared hook state after a successful save, and `fromEnv` is true when
|
|
|
33
33
|
`{PREFIX}_LOG_LEVEL` locks the value. Applications should not add a separate
|
|
34
34
|
configuration fetch or initializer component.
|
|
35
35
|
|
|
36
|
-
Security lists only show useful active credentials.
|
|
36
|
+
Security lists only show useful active user credentials. Managed API keys created by
|
|
37
|
+
server-side applications, and their metadata, are not returned by the browser API-key
|
|
38
|
+
list or rendered in Settings. Expired API keys are purged before listing, and revoked
|
|
39
|
+
or expired device-auth refresh sessions are not shown. Revoked refresh sessions may
|
|
40
|
+
remain in storage when needed for token-reuse protection, but they are hidden from Settings.
|
|
37
41
|
|
|
38
42
|
Destructive actions in Settings use the framework `ConfirmDialog` before mutating. This includes deleting users, deleting API keys, deleting passkeys, revoking device-auth sessions, and killing the server.
|
|
39
43
|
|
package/docs/ui-guidelines.md
CHANGED
|
@@ -44,7 +44,8 @@ Use these first:
|
|
|
44
44
|
| `Panel` | Cards/sections; use `actions` for a top-right menu/action area |
|
|
45
45
|
| `ActionMenu` | Three-line action menu for secondary surfaces; entity-level shell menus should usually come from `SidebarNode.actions` so the framework renders them in the sidebar context menu and fixed title bar |
|
|
46
46
|
| `Button` / `IconButton` | Form submission and true inline controls; prefer action menus for entity/app commands |
|
|
47
|
-
| `Badge` |
|
|
47
|
+
| `Badge` | Generic status/count labels; preserves the supplied text casing |
|
|
48
|
+
| `StatusBadge` | Status labels with the shared uppercase and letter-spacing treatment |
|
|
48
49
|
| `EntityHeader` | Main-content entity title/description/actions |
|
|
49
50
|
| `DataList` / `DataListRow` | Lists with title, description, metadata, badge and actions |
|
|
50
51
|
| `TextField`, `TextAreaField`, `SelectField` | Forms |
|
package/package.json
CHANGED
package/src/contracts/index.ts
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
import type { ApiKeySummary, CreatedApiKeyResponse, CurrentUser } from "../../contracts";
|
|
2
|
-
import type { WebAppStore } from "./store";
|
|
2
|
+
import type { ApiKeyRecord, WebAppStore } from "./store";
|
|
3
3
|
import { nowIso, randomToken, sha256, secureEqual, isExpired } from "./crypto";
|
|
4
4
|
import { AuthError } from "./types";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
export interface ManagedApiKeySummary extends ApiKeySummary {
|
|
7
|
+
kind: "managed";
|
|
8
|
+
managedBy?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CreatedManagedApiKeyResponse {
|
|
12
|
+
key: ManagedApiKeySummary;
|
|
13
|
+
token: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ApiKeyCreationOptions {
|
|
17
|
+
name?: string;
|
|
18
|
+
scopes?: string[];
|
|
19
|
+
prefix?: string;
|
|
20
|
+
expiresAt?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ManagedApiKeyCreationOptions extends ApiKeyCreationOptions {
|
|
24
|
+
managedBy?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function summarize(record: ApiKeyRecord): ApiKeySummary {
|
|
7
28
|
return {
|
|
8
29
|
id: record.id,
|
|
9
30
|
name: record.name,
|
|
@@ -15,17 +36,31 @@ function summarize(record: { tokenHash?: string } & ApiKeySummary): ApiKeySummar
|
|
|
15
36
|
};
|
|
16
37
|
}
|
|
17
38
|
|
|
39
|
+
function summarizeManaged(record: ApiKeyRecord): ManagedApiKeySummary {
|
|
40
|
+
return {
|
|
41
|
+
...summarize(record),
|
|
42
|
+
kind: "managed",
|
|
43
|
+
managedBy: record.managedBy,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
18
47
|
export function listApiKeys(store: WebAppStore, userId: string): ApiKeySummary[] {
|
|
19
48
|
store.deleteExpiredApiKeys?.(nowIso());
|
|
20
49
|
return store.listApiKeys(userId)
|
|
50
|
+
.filter((record) => record.userId === userId && record.kind === "user")
|
|
21
51
|
.filter((record) => !record.expiresAt || !isExpired(record.expiresAt))
|
|
22
52
|
.map(summarize);
|
|
23
53
|
}
|
|
24
54
|
|
|
25
|
-
|
|
55
|
+
function createApiKeyRecord(
|
|
56
|
+
user: CurrentUser,
|
|
57
|
+
input: ApiKeyCreationOptions,
|
|
58
|
+
kind: ApiKeyRecord["kind"],
|
|
59
|
+
managedBy?: string,
|
|
60
|
+
): { record: ApiKeyRecord; token: string } {
|
|
26
61
|
const prefix = input.prefix ?? "wapp";
|
|
27
62
|
const token = `${prefix}_${randomToken(32)}`;
|
|
28
|
-
const record = {
|
|
63
|
+
const record: ApiKeyRecord = {
|
|
29
64
|
id: crypto.randomUUID(),
|
|
30
65
|
userId: user.id,
|
|
31
66
|
name: input.name?.trim() || "API key",
|
|
@@ -34,15 +69,53 @@ export function createApiKey(store: WebAppStore, user: CurrentUser, input: { nam
|
|
|
34
69
|
scopes: input.scopes?.length ? input.scopes : ["*"],
|
|
35
70
|
createdAt: nowIso(),
|
|
36
71
|
expiresAt: input.expiresAt,
|
|
72
|
+
kind,
|
|
73
|
+
managedBy,
|
|
37
74
|
};
|
|
75
|
+
return { record, token };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createApiKey(store: WebAppStore, user: CurrentUser, input: ApiKeyCreationOptions): CreatedApiKeyResponse {
|
|
79
|
+
const { record, token } = createApiKeyRecord(user, input, "user");
|
|
38
80
|
store.saveApiKey(record);
|
|
39
81
|
return { key: summarize(record), token };
|
|
40
82
|
}
|
|
41
83
|
|
|
42
84
|
export function deleteApiKey(store: WebAppStore, userId: string, id: string): boolean {
|
|
85
|
+
const record = store.listApiKeys(userId).find((candidate) => candidate.id === id && candidate.userId === userId);
|
|
86
|
+
if (!record || record.kind !== "user") {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
43
89
|
return store.deleteApiKey(id, userId);
|
|
44
90
|
}
|
|
45
91
|
|
|
92
|
+
export function createManagedApiKey(store: WebAppStore, user: CurrentUser, input: ManagedApiKeyCreationOptions = {}): CreatedManagedApiKeyResponse {
|
|
93
|
+
const { record, token } = createApiKeyRecord(user, input, "managed", input.managedBy);
|
|
94
|
+
store.saveApiKey(record);
|
|
95
|
+
return { key: summarizeManaged(record), token };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function listManagedApiKeys(store: WebAppStore, userId: string, managedBy?: string): ManagedApiKeySummary[] {
|
|
99
|
+
store.deleteExpiredApiKeys?.(nowIso());
|
|
100
|
+
return store.listApiKeys(userId)
|
|
101
|
+
.filter((record) => record.userId === userId && record.kind === "managed")
|
|
102
|
+
.filter((record) => managedBy === undefined || record.managedBy === managedBy)
|
|
103
|
+
.filter((record) => !record.expiresAt || !isExpired(record.expiresAt))
|
|
104
|
+
.map(summarizeManaged);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function revokeManagedApiKey(store: WebAppStore, id: string, userId?: string): boolean {
|
|
108
|
+
const record = store.listApiKeys(userId).find((candidate) =>
|
|
109
|
+
candidate.id === id
|
|
110
|
+
&& candidate.kind === "managed"
|
|
111
|
+
&& (userId === undefined || candidate.userId === userId)
|
|
112
|
+
);
|
|
113
|
+
if (!record) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return store.deleteApiKey(id, record.userId);
|
|
117
|
+
}
|
|
118
|
+
|
|
46
119
|
export function authenticateApiKey(store: WebAppStore, token: string): { user: CurrentUser; apiKeyId: string; scopes: string[] } | undefined {
|
|
47
120
|
const tokenHash = sha256(token);
|
|
48
121
|
const record = store.getApiKeyByHash(tokenHash);
|
|
@@ -53,7 +126,7 @@ export function authenticateApiKey(store: WebAppStore, token: string): { user: C
|
|
|
53
126
|
return undefined;
|
|
54
127
|
}
|
|
55
128
|
const user = store.getUserById(record.userId);
|
|
56
|
-
if (!user) {
|
|
129
|
+
if (!user || user.disabledAt) {
|
|
57
130
|
return undefined;
|
|
58
131
|
}
|
|
59
132
|
store.touchApiKey(record.id, nowIso());
|
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
UserSetupLinkRecord,
|
|
12
12
|
WebAppStore,
|
|
13
13
|
} from "./store";
|
|
14
|
-
import type { LogLevelName, ThemePreference, WebAppUserRole } from "../../contracts";
|
|
14
|
+
import type { ApiKeyKind, LogLevelName, ThemePreference, WebAppUserRole } from "../../contracts";
|
|
15
15
|
import { createUserRecord } from "./users";
|
|
16
16
|
|
|
17
17
|
type Row = Record<string, unknown>;
|
|
@@ -132,6 +132,8 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
132
132
|
created_at TEXT NOT NULL,
|
|
133
133
|
last_used_at TEXT,
|
|
134
134
|
expires_at TEXT,
|
|
135
|
+
kind TEXT NOT NULL DEFAULT 'user',
|
|
136
|
+
managed_by TEXT,
|
|
135
137
|
FOREIGN KEY (user_id) REFERENCES webapp_users(id) ON DELETE CASCADE
|
|
136
138
|
);
|
|
137
139
|
CREATE TABLE IF NOT EXISTS webapp_device_auth_requests (
|
|
@@ -175,6 +177,19 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
175
177
|
`);
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
function migrateApiKeySchema(): void {
|
|
181
|
+
if (!tableExists("webapp_api_keys")) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!hasColumn("webapp_api_keys", "kind")) {
|
|
185
|
+
db.exec("ALTER TABLE webapp_api_keys ADD COLUMN kind TEXT NOT NULL DEFAULT 'user';");
|
|
186
|
+
}
|
|
187
|
+
if (!hasColumn("webapp_api_keys", "managed_by")) {
|
|
188
|
+
db.exec("ALTER TABLE webapp_api_keys ADD COLUMN managed_by TEXT;");
|
|
189
|
+
}
|
|
190
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_webapp_api_keys_managed ON webapp_api_keys(user_id, kind, managed_by);");
|
|
191
|
+
}
|
|
192
|
+
|
|
178
193
|
function migrateSingleUserSchema(): void {
|
|
179
194
|
const legacySources = [
|
|
180
195
|
["webapp_preferences", tableExists("webapp_preferences") && !hasColumn("webapp_preferences", "user_id")],
|
|
@@ -277,6 +292,7 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
277
292
|
function initialize(): void {
|
|
278
293
|
migrateSingleUserSchema();
|
|
279
294
|
createSchema();
|
|
295
|
+
migrateApiKeySchema();
|
|
280
296
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
281
297
|
}
|
|
282
298
|
|
|
@@ -356,6 +372,7 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
356
372
|
}
|
|
357
373
|
|
|
358
374
|
function mapApiKey(row: Row): ApiKeyRecord {
|
|
375
|
+
const kind: ApiKeyKind = row["kind"] === "managed" ? "managed" : "user";
|
|
359
376
|
return {
|
|
360
377
|
id: text(row["id"]),
|
|
361
378
|
userId: text(row["user_id"]),
|
|
@@ -366,6 +383,8 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
366
383
|
createdAt: text(row["created_at"]),
|
|
367
384
|
lastUsedAt: optionalText(row["last_used_at"]),
|
|
368
385
|
expiresAt: optionalText(row["expires_at"]),
|
|
386
|
+
kind,
|
|
387
|
+
managedBy: optionalText(row["managed_by"]),
|
|
369
388
|
};
|
|
370
389
|
}
|
|
371
390
|
|
|
@@ -544,9 +563,22 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
544
563
|
},
|
|
545
564
|
saveApiKey: (record) => {
|
|
546
565
|
db.query(`
|
|
547
|
-
INSERT INTO webapp_api_keys
|
|
548
|
-
|
|
549
|
-
|
|
566
|
+
INSERT INTO webapp_api_keys
|
|
567
|
+
(id, user_id, name, prefix, token_hash, scopes, created_at, last_used_at, expires_at, kind, managed_by)
|
|
568
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
569
|
+
`).run(
|
|
570
|
+
record.id,
|
|
571
|
+
record.userId,
|
|
572
|
+
record.name,
|
|
573
|
+
record.prefix,
|
|
574
|
+
record.tokenHash,
|
|
575
|
+
JSON.stringify(record.scopes),
|
|
576
|
+
record.createdAt,
|
|
577
|
+
record.lastUsedAt ?? null,
|
|
578
|
+
record.expiresAt ?? null,
|
|
579
|
+
record.kind,
|
|
580
|
+
record.managedBy ?? null,
|
|
581
|
+
);
|
|
550
582
|
},
|
|
551
583
|
touchApiKey: (id, lastUsedAt) => {
|
|
552
584
|
db.query("UPDATE webapp_api_keys SET last_used_at = ? WHERE id = ?").run(lastUsedAt, id);
|
package/src/server/auth/store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApiKeySummary, AuditEventSummary, LogLevelName, ThemePreference, WebAppUserRole, WebAppUserSummary } from "../../contracts";
|
|
1
|
+
import type { ApiKeyKind, ApiKeySummary, AuditEventSummary, LogLevelName, ThemePreference, WebAppUserRole, WebAppUserSummary } from "../../contracts";
|
|
2
2
|
|
|
3
3
|
export interface UserRecord extends WebAppUserSummary {
|
|
4
4
|
authVersion: number;
|
|
@@ -36,6 +36,8 @@ export interface StoredPasskey {
|
|
|
36
36
|
export interface ApiKeyRecord extends ApiKeySummary {
|
|
37
37
|
userId: string;
|
|
38
38
|
tokenHash: string;
|
|
39
|
+
kind: ApiKeyKind;
|
|
40
|
+
managedBy?: string;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export interface DeviceAuthRequestRecord {
|
|
@@ -386,6 +386,7 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
386
386
|
const target = store.getUserById(userId);
|
|
387
387
|
if (!target) return notFound();
|
|
388
388
|
if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be deleted");
|
|
389
|
+
store.deleteApiKeysForUser(userId);
|
|
389
390
|
if (!store.deleteUser(userId)) return notFound();
|
|
390
391
|
audit(store, { eventType: "user_deleted", actorUserId: actor.id, metadata: { deletedUserId: userId, username: target.username } });
|
|
391
392
|
return successResponse();
|
package/src/server/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./responses";
|
|
|
5
5
|
export * from "./public-assets";
|
|
6
6
|
export * from "./create-web-app-server";
|
|
7
7
|
export { getRequestBaseUrl, getRequestOriginInfo, type RequestOriginInfo } from "./auth/request-origin";
|
|
8
|
+
export * from "./auth/api-keys";
|
|
8
9
|
export * from "./auth/store";
|
|
9
10
|
export * from "./auth/sqlite-store";
|
|
10
11
|
export * from "./realtime/bus";
|
|
@@ -32,17 +32,27 @@ export function IconButton({
|
|
|
32
32
|
|
|
33
33
|
export type BadgeSize = "sm" | "md";
|
|
34
34
|
|
|
35
|
+
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|
36
|
+
variant?: BadgeVariant;
|
|
37
|
+
size?: BadgeSize;
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
export function Badge({
|
|
36
42
|
variant = "default",
|
|
37
43
|
size = "sm",
|
|
38
44
|
className = "",
|
|
39
45
|
children,
|
|
40
46
|
...props
|
|
41
|
-
}:
|
|
47
|
+
}: BadgeProps) {
|
|
42
48
|
const sizeClass = size === "md" ? "wapp-badge-md" : "";
|
|
43
49
|
return <span {...props} className={["wapp-badge", `wapp-badge-${variant}`, sizeClass, className].filter(Boolean).join(" ")}>{children}</span>;
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
export function StatusBadge({ className = "", ...props }: BadgeProps) {
|
|
53
|
+
return <Badge {...props} className={["wapp-status-badge", className].filter(Boolean).join(" ")} />;
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
export type PageLayout = "padded" | "full";
|
|
47
57
|
|
|
48
58
|
export function Page({
|
package/src/web/styles.css
CHANGED