diff --git a/.agents/skills/UNSLOP.md b/.agents/skills/UNSLOP.md new file mode 100644 index 00000000..5b624a12 --- /dev/null +++ b/.agents/skills/UNSLOP.md @@ -0,0 +1,67 @@ +--- +name: unslop +description: Cut AI tells from any writing. Must always apply. +disable-model-invocation: true +--- + +# Unslop + +Edit text to remove AI patterns. + +## Process + +1. Scan for the patterns below. +2. Rewrite. Preserve meaning, match intended tone. + +## Patterns to detect and fix + +Rule numbers are stable ids that other skills cite. A removed rule leaves a gap. + +### Content + +3. **Superficial -ing phrases.** "highlighting...", "ensuring...", "reflecting...", "showcasing...", "fostering...". Delete or expand with real sources. +5. **Vague attributions.** "Experts believe", "Industry reports suggest", "Some critics argue". Name the source or delete. + +### Language + +7. **AI vocabulary.** Additionally, crucial, delve, enduring, enhance, fostering, garner, interplay, intricate, landscape (abstract), pivotal, showcase, tapestry (abstract), testament, underscore, vibrant. Replace with plain words. +8. **Fancy ways to say "is".** "serves as", "stands as", "boasts", "features". Just say "is" or "has". +9. **"Not just X, but Y."** State the point directly instead. +10. **Rule of three.** Forcing ideas into groups of three. Use the natural number. +11. **Synonym cycling.** Protagonist, main character, central figure, hero all in one paragraph. Pick one, repeat it. +12. **False ranges.** "from X to Y" where X and Y aren't on a meaningful scale. List topics directly. + +### Style + +13. **Em dash overuse.** Avoid em dashes entirely. Use periods or commas only (no parentheses, no en dashes, no hyphen-as-dash substitutes). If a thought needs separation, end the sentence or use a comma. +14. **Colon overuse.** Colons are fine before a list or example. Not as mid-sentence connectors. "If you're coming from traditional automation: instead of registering event handlers, you describe conditions" adds nothing with the colon. Rewrite to let the point stand on its own without comparison framing. "Describing when the scheduler should fire works best as plain English." Same meaning, no crutch punctuation. +15. **Boldface overuse.** Don't bold every proper noun or acronym. +16. **Inline-header lists.** The tell is a bold label and colon that restates the line: "**Performance:** Performance improved...". Convert those to prose. A bold lead-in that ends in a period, names the item, and is followed by genuinely new detail ("**Schema in TypeScript.** Tables live in one file.") is fine, not a tell. +17. **Title case headings.** Use sentence case. +18. **Decorative emojis.** Remove from headings and bullets. +19. **Curly quotes.** Replace with straight quotes. + +### Communication artifacts + +20. **Chatbot phrases.** "I hope this helps!", "Let me know if...", "Of course!", "Certainly!", "Found the smoking gun!" Remove. +22. **Sycophantic tone.** "Great question! You're absolutely right!" Respond directly. + +### Filler + +23. **Filler phrases.** "In order to" becomes "To". "Due to the fact that" becomes "Because". "It is important to note that" gets deleted. +24. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may". +25. **Generic conclusions.** "The future looks bright." State specific plans or facts. + +### Jargon + +26. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word. + +### Plain speech + +27. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write that. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it. +28. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence. +29. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter. +30. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong. +31. **Prefer the plain word.** "utilize" becomes "use", "leverage" becomes "use", "facilitate" becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer. +32. **Mannered prose.** Metaphor or flourish where a literal phrase exists: aphorisms ("wire it or delete it"), rhetorical fragments for effect, personified code ("the plan holds it"), figurative verbs ("rides along", "stands on"), stock framing phrases. "A dial worth turning" becomes "a parameter worth varying". Say what you mean. Rule 26 covers the metaphor nouns. +33. **Over-compression.** Dropped articles, verbless fragments, symbol-speak, and abbreviations that make the reader decode instead of read. "Parser rejects bad date → exit 2, no write" becomes "The parser rejects a bad date, exits with code 2, and writes nothing." Write whole sentences with their articles and verbs, and spell out arrows and abbreviations. \ No newline at end of file diff --git a/.github/workflows/experimental.yml b/.github/workflows/experimental.yml new file mode 100644 index 00000000..77233354 --- /dev/null +++ b/.github/workflows/experimental.yml @@ -0,0 +1,44 @@ +name: Build and Publish experimental + +on: + push: + branches: + - experimental + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Bun + uses: oven-sh/setup-bun@v1 + + - name: Install dependencies and build web app + run: | + cd apps/web + bun install + bun run build + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push multi-architecture Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ secrets.DOCKER_USERNAME }}/dashwise:experimental diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index db48e3d2..fb317510 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -290,6 +290,14 @@ paths: - description - iconUrl additionalProperties: false + /links/home/groups/cleanup: + post: + tags: + - links + summary: Delete unused home link groups + responses: + "200": + $ref: "#/components/responses/JsonOk" /links/home/groups: get: tags: diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 8d0b095f..30f9ec4e 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -11,7 +11,8 @@ import { registerSessionConnection, subscribeActivity, } from "./lib/activity"; -import { ensureSession } from "./lib/data/sessions"; + +import { authenticateDeviceSocket, closeDeviceRequest, createDeviceRequest } from "./lib/device-code"; import { jobsApi, registerJobsCron } from "./jobs/index"; import { startPocketbase } from "./pocketbase"; import { createLogger } from "./lib/logger"; @@ -20,7 +21,7 @@ import { getNotifications } from "./lib/data/notifications/items"; import { listIntegrations } from "./lib/data/integrations"; import { getUpcomingEvents } from "./lib/calendar"; import { systemAgentClient } from "./lib/systemAgent"; -import { readAuth, readSessionMetadata, requireAuth } from "./routes/shared"; +import { requireAuth } from "./routes/shared"; import authRoute from "./routes/auth.route"; import sessionsRoute from "./routes/sessions.route"; import systemRoute from "./routes/system.route"; @@ -70,21 +71,6 @@ app.use("*", async (c, next) => { app.use("*", cors({ origin: "*" })); -// Session identity is deliberately independent from the auth token. Touch the -// current device on every authenticated API request that carries its stable id. -app.use("/api/v1/*", async (c, next) => { - const auth = readAuth(c); - if (auth.token && auth.sessionId) { - try { - const { pb, userId } = await requireAuth(auth); - await ensureSession(pb, userId, auth.sessionId, readSessionMetadata(c)); - } catch { - // The route handler remains responsible for returning auth errors. This - // middleware should not turn a missing/expired session touch into one. - } - } - await next(); -}); app.route("/", authRoute); app.route("/", sessionsRoute); @@ -93,7 +79,40 @@ app.route("/", dataRoute); app.get("/health", (c) => c.json({ status: "ok" })); -app.get("/api/v1/activity", upgradeWebSocket((c) => { +// Device login never accepts credentials in the URL. The first WebSocket frame +// is only a protocol acknowledgement; the server owns the hidden secret. +app.get("/api/v1/auth/device-code", upgradeWebSocket((c) => { + let requestId = ""; + return { + onOpen(_event, ws) { + try { + if (config.ENVIRONMENT === "production" && !["https", "wss"].includes((c.req.header("x-forwarded-proto") ?? new URL(c.req.url).protocol.replace(":", "")))) { + ws.close(1008, "HTTPS is required"); + return; + } + const request = createDeviceRequest(ws, c.req.header("x-forwarded-for")?.split(",")[0] ?? "unknown", c.req.header("user-agent") ?? "unknown"); + requestId = request.id; + ws.send(JSON.stringify({ type: "device-code", code: request.code, secret: request.secret, requestId: request.id, expiresAt: request.expiresAt })); + } catch (error) { + ws.send(JSON.stringify({ type: "error", message: error instanceof Error ? error.message : "Device login unavailable" })); + ws.close(1013, "Device login unavailable"); + } + }, + onMessage(event, ws) { + try { + const message = JSON.parse(String(event.data)); + if (message.type === "authenticate" && requestId && authenticateDeviceSocket(requestId, String(message.secret ?? ""))) { + ws.send(JSON.stringify({ type: "authenticated" })); + } else { + ws.close(1008, "Invalid device login"); + } + } catch { ws.close(1008, "Invalid device login"); } + }, + onClose() { if (requestId) closeDeviceRequest(requestId); }, + }; +})); + +app.get("/api/v1/activity", upgradeWebSocket((c) => { let refreshTimer: ReturnType | undefined; let unsubscribeActivity: (() => void) | undefined; let unregisterSessionConnection: (() => void) | undefined; @@ -103,14 +122,12 @@ app.get("/api/v1/activity", upgradeWebSocket((c) => { return { async onOpen(_event, ws) { const token = c.req.query("token") || ""; - const sessionId = c.req.query("sessionId") || c.req.header("x-session-id") || null; + const requestedSessionId = c.req.query("sessionId") || c.req.header("x-session-id") || null; try { - const { userId, pb } = await requireAuth({ token, sessionId }); - const session = await ensureSession(pb, userId, sessionId, readSessionMetadata(c)); - if (!session) throw new Error("A valid session id is required"); + const { userId, pb, sessionId } = await requireAuth({ token, sessionId: requestedSessionId }); connectedUserId = userId; - connectedSessionId = session.sessionId; - unregisterSessionConnection = registerSessionConnection(userId, session.sessionId, ws); + connectedSessionId = sessionId; + unregisterSessionConnection = registerSessionConnection(userId, sessionId, ws); let calendarEvents: Array> = []; let calendarRefreshedAt = 0; let calendarRefresh: Promise | null = null; @@ -202,8 +219,7 @@ app.get("/api/v1/monitoring/ssh-hosts/:id/console", upgradeWebSocket((c) => { const hostId = c.req.param("id") || ""; try { - const { userId, pb } = await requireAuth({ token, sessionId }); - await ensureSession(pb, userId, sessionId, readSessionMetadata(c)); + const { userId } = await requireAuth({ token, sessionId }); const host = await getMonitoringSshHostById(userId, hostId); if (!host) { ws.send(JSON.stringify({ type: "error", message: "SSH host not found" })); @@ -291,8 +307,7 @@ app.get("/api/v1/monitoring/hosts/:id/stats/live", upgradeWebSocket((c) => { const token = c.req.query("token") || c.req.header("Authorization")?.replace(/^Bearer\s+/i, "") || ""; const sessionId = c.req.query("sessionId") || c.req.header("x-session-id") || null; try { - const { userId, pb } = await requireAuth({ token, sessionId }); - await ensureSession(pb, userId, sessionId, readSessionMetadata(c)); + const { userId } = await requireAuth({ token, sessionId }); const host = await getSystemAgentHostById(userId, c.req.param("id") || ""); if (!host) { ws.close(1008, "Monitoring host not found"); diff --git a/apps/backend/src/lib/data/auth.test.ts b/apps/backend/src/lib/data/auth.test.ts new file mode 100644 index 00000000..c903e5c8 --- /dev/null +++ b/apps/backend/src/lib/data/auth.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; + +import { registerDashwiseSDKConnector } from "../pb/pocketbase"; +import { ApiActionError, requireUserAuth } from "./auth"; +import { hashSessionToken } from "./sessions"; + +type AuthFixture = { + refreshCalls: string[]; + setRefreshResult: (result: "success" | "unauthorized") => void; +}; + +function installAuthFixture(): AuthFixture { + const refreshCalls: string[] = []; + let refreshResult: "success" | "unauthorized" = "unauthorized"; + let storedToken = "internal-pb-token"; + const session = { + id: "record-id", + sessionId: "session-id", + user: "verified-user-id", + pocketbaseToken: storedToken, + lastSeenAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; + + const connector = { + async getSuperuserClient() { + return { + collection() { + return { + async getFirstListItem(filter: string) { + if (filter !== `tokenHash = "${hashSessionToken("dws_test-session-token")}"`) throw new Error("not found"); + return session; + }, + async update(_id: string, value: Record) { + storedToken = value.pocketbaseToken; + return session; + }, + }; + }, + }; + }, + createServerClient() { + let token = ""; + return { + authStore: { + get token() { return token; }, + save(nextToken: string) { token = nextToken; }, + }, + collection() { + return { + async authRefresh() { + refreshCalls.push(token); + if (refreshResult === "unauthorized") { + throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); + } + return { record: { id: "verified-user-id" }, token }; + }, + }; + }, + }; + }, + }; + + registerDashwiseSDKConnector(connector as never); + return { + refreshCalls, + setRefreshResult(result) { + refreshResult = result; + }, + }; +} + +describe("requireUserAuth", () => { + test("rejects PocketBase JWTs because only Dashwise session credentials are accepted", async () => { + installAuthFixture(); + await expect(requireUserAuth({ token: "eyJhbGciOiJIUzI1NiJ9.payload.signature" })).rejects.toMatchObject({ status: 401 }); + }); + + test("resolves user identity through an active Dashwise session", async () => { + const fixture = installAuthFixture(); + fixture.setRefreshResult("success"); + + await expect(requireUserAuth({ token: "dws_test-session-token" })).resolves.toMatchObject({ + userId: "verified-user-id", + sessionId: "session-id", + }); + expect(fixture.refreshCalls).toEqual(["internal-pb-token"]); + }); +}); diff --git a/apps/backend/src/lib/data/auth.ts b/apps/backend/src/lib/data/auth.ts index f4a5808e..10207a53 100644 --- a/apps/backend/src/lib/data/auth.ts +++ b/apps/backend/src/lib/data/auth.ts @@ -1,4 +1,6 @@ -import { ClientResponseError, getServerPB } from "../pb/pocketbase"; +import type { RecordModel } from "pocketbase"; +import { ClientResponseError, getServerPB, getSuperuserPB } from "../pb/pocketbase"; +import { createSession, hashSessionToken } from "./sessions"; import { defaultHomeConfig } from "@dashwise/assets"; import type { UsersResponse } from "@dashwise/types"; @@ -106,114 +108,54 @@ export type AuthUserRecord = Partial< [key: string]: unknown; }; -const AUTH_CACHE_TTL_MS = 5000; -const AUTH_REFRESH_LEEWAY_MS = 30_000; -const tokenAuthCache = new Map(); - -function decodeJwtPayload(token: string): Record | null { - const parts = token.split("."); - if (parts.length < 2) { - return null; +export async function requireUserAuth(auth?: ActionAuth) { + const token = auth?.token?.trim(); + if (!token?.startsWith("dws_") || token.length > 128) { + throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); } + const admin = await getSuperuserPB(); + let session: RecordModel & { + sessionId: string; + user: string; + tokenHash: string; + pocketbaseToken?: string; + expiresAt?: string; + revokedAt?: string; + lastSeenAt?: string; + }; try { - const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); - const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); - const decoded = Buffer.from(padded, "base64").toString("utf8"); - return JSON.parse(decoded); + session = await admin.collection("sessions").getFirstListItem( + `tokenHash = "${hashSessionToken(token)}"`, + ); } catch { - return null; - } -} - -function readTokenClaims(token: string) { - const payload = decodeJwtPayload(token); - if (!payload) { - return { userId: null, expMs: null }; - } - - const payloadRecord = payload as Record; - const userIdCandidates = [payloadRecord.id, payloadRecord.sub, payloadRecord.userId, payloadRecord.recordId]; - const userId = userIdCandidates.find((value) => typeof value === "string") ?? null; - const expMs = typeof payloadRecord.exp === "number" ? payloadRecord.exp * 1000 : null; - - return { userId, expMs }; -} - -function readCachedUserId(token: string): string | null { - const cached = tokenAuthCache.get(token); - if (!cached) { - return null; - } - - if (cached.expiresAt <= Date.now()) { - tokenAuthCache.delete(token); - return null; + throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); } - return cached.userId; -} - -function cacheTokenUserId(token: string, userId: string, expMs: number | null) { const now = Date.now(); - const baseExpiresAt = now + AUTH_CACHE_TTL_MS; - const tokenSafeUntil = expMs ? expMs - AUTH_REFRESH_LEEWAY_MS : null; - const expiresAt = tokenSafeUntil ? Math.min(baseExpiresAt, tokenSafeUntil) : baseExpiresAt; - - if (expiresAt <= now) { - return; - } - - tokenAuthCache.set(token, { userId, expiresAt }); -} - -export async function requireUserAuth(auth?: ActionAuth) { - if (!auth?.token) { + const idleExpired = !session.lastSeenAt || Date.parse(session.lastSeenAt) + 30 * 24 * 60 * 60 * 1000 <= now; + if (session.revokedAt || !session.expiresAt || Date.parse(session.expiresAt) <= now || idleExpired || !session.pocketbaseToken) { throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); } - const token = auth.token; const pb = getServerPB(); - pb.authStore.save(token, null); - - const cachedUserId = readCachedUserId(token); - if (cachedUserId) { - return { pb, userId: cachedUserId, authModel: null }; - } - - const now = Date.now(); - const { userId: claimedUserId, expMs } = readTokenClaims(token); - const shouldRefresh = - !pb.authStore.isValid || - !claimedUserId || - !expMs || - expMs - now <= AUTH_REFRESH_LEEWAY_MS; - - if (!shouldRefresh) { - cacheTokenUserId(token, claimedUserId, expMs); - return { pb, userId: claimedUserId, authModel: null }; - } - + pb.authStore.save(session.pocketbaseToken, null); try { const authModel = await pb.collection("users").authRefresh(); const userId = authModel?.record?.id; - - if (!userId) { + if (!userId || userId !== session.user) { throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); } - - cacheTokenUserId(pb.authStore.token || token, userId, readTokenClaims(pb.authStore.token || token).expMs); - - return { pb, userId, authModel }; + await admin.collection("sessions").update(session.id, { + lastSeenAt: new Date().toISOString(), + pocketbaseToken: pb.authStore.token || session.pocketbaseToken, + }); + return { pb, userId, authModel, sessionId: session.sessionId }; } catch (error) { - if (error instanceof ApiActionError) { - throw error; - } - + if (error instanceof ApiActionError) throw error; if (error instanceof ClientResponseError && error.status === 401) { throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); } - throw error; } } @@ -261,7 +203,10 @@ export async function loginUser( } } - return { token: authData.token, user }; + const { token, session } = await createSession(pb, user.id, { clientType: "browser" }); + const safeUser = { ...user }; + delete safeUser.totpSecret; + return { token, sessionId: session.sessionId, user: safeUser }; } export async function signupUser(payload: { @@ -330,24 +275,10 @@ export async function signupUser(payload: { } export async function validateAuthToken(token: string) { - if (!token) { - throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); - } - - const pb = getServerPB(); - pb.authStore.save(token, null); - const authModel = await pb.collection("users").authRefresh(); - const userId = authModel?.record?.id ?? null; - - if (!userId) { - throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); - } - - return { - success: true, - token: pb.authStore.token ?? token, - user: authModel.record, - }; + const { authModel } = await requireUserAuth({ token }); + const user = { ...authModel.record }; + delete user.totpSecret; + return { success: true, user }; } export type ChangePasswordRequest = { @@ -384,13 +315,7 @@ export async function changePassword( ); } - const pb = getServerPB(); - pb.authStore.save(token, null); - - const authModel = await pb.collection("users").authRefresh(); - if (!authModel?.record) { - throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); - } + const { pb, authModel } = await requireUserAuth({ token }); const email = bodyEmail ?? authModel.record.email; if (!email) { @@ -410,16 +335,7 @@ export async function changePassword( passwordConfirm: confirmPassword, }); - try { - await pb.collection("users").authWithPassword(email, newPassword); - } catch { - return { message: "Password changed - Please log in again." }; - } - - return { - message: "Password changed successfully", - token: pb.authStore.token ?? null, - }; + return { message: "Password changed successfully. Please log in again." }; } export async function deleteAccount( diff --git a/apps/backend/src/lib/data/links.ts b/apps/backend/src/lib/data/links.ts index 9a6ccf33..80bde10c 100644 --- a/apps/backend/src/lib/data/links.ts +++ b/apps/backend/src/lib/data/links.ts @@ -207,9 +207,10 @@ export async function getLinksItems(userId: string, listId: string, folderId?: s })); } -export async function getLinksTags() { +export async function getLinksTags(userId: string) { const pb = getServerPB(); const records = await pb.collection("linksTags").getFullList({ + filter: `user = "${userId}"`, sort: "name", }); @@ -231,11 +232,10 @@ export async function createLinkTag( throw new Error("Tag name is required"); } - void userId; - const record = await pb.collection("linksTags").create({ name: normalizedName, color: data.color ?? "", + user: userId, }); return { @@ -274,7 +274,8 @@ export async function updateLinkTag( data: { name?: string; color?: string }, ): Promise<{ id: string; name: string; color: string }> { const pb = getServerPB(); - void userId; + const tag = await pb.collection("linksTags").getOne(tagId); + if (tag.user !== userId) throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); const record = await pb.collection("linksTags").update(tagId, { ...(data.name !== undefined && { name: String(data.name).trim() }), @@ -546,6 +547,49 @@ async function getOrCreateFolderInList( return created.id as string; } +export async function deleteUnusedHomeLinkGroups(userId: string) { + const pb = getServerPB(); + const homeCollection = await pb + .collection("linksLists") + .getFirstListItem(`user = "${userId}" && type = "home"`) + .catch(() => null); + + if (!homeCollection) return { deletedGroups: [] as string[] }; + + const [folders, links] = await Promise.all([ + pb.collection("linksFolders").getFullList({ filter: `list = "${homeCollection.id}"` }), + pb.collection("linkItems").getFullList({ filter: `collection = "${homeCollection.id}"` }), + ]); + const childrenByParent = new Map(); + for (const folder of folders) { + const parentId = String(folder.parentFolder || ""); + const children = childrenByParent.get(parentId) ?? []; + children.push(folder); + childrenByParent.set(parentId, children); + } + + const deletedGroups: string[] = []; + for (const group of childrenByParent.get("") ?? []) { + const subtreeIds = new Set(); + const subtree = [group]; + while (subtree.length) { + const folder = subtree.pop()!; + subtreeIds.add(String(folder.id)); + subtree.push(...(childrenByParent.get(String(folder.id)) ?? [])); + } + + if (links.some((link: any) => subtreeIds.has(String(link.folder || "")))) continue; + + const descendants = [...subtreeIds].reverse(); + for (const folderId of descendants) { + await pb.collection("linksFolders").delete(folderId); + } + deletedGroups.push(String(group.name || "")); + } + + return { deletedGroups }; +} + export async function getHomeLinkGroups(userId: string): Promise { const pb = getServerPB(); const homeCollection = await pb diff --git a/apps/backend/src/lib/data/pocketbase-permissions.test.ts b/apps/backend/src/lib/data/pocketbase-permissions.test.ts new file mode 100644 index 00000000..32affa59 --- /dev/null +++ b/apps/backend/src/lib/data/pocketbase-permissions.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; + +const migrationsDirectory = `${import.meta.dir}/../../../../../pocketbase/migrations`; + +async function readMigration(name: string) { + return Bun.file(`${migrationsDirectory}/${name}`).text(); +} + +describe("PocketBase collection permission migrations", () => { + test("link collections require an authenticated owner", async () => { + const migration = await readMigration("1785000006_secure_link_collection_rules.js"); + + expect(migration).not.toContain('"createRule": ""'); + expect(migration).not.toContain('"listRule": ""'); + expect(migration).toContain("@request.body.user = @request.auth.id"); + expect(migration).toContain("@request.body.list.user = @request.auth.id"); + expect(migration).toContain("@request.body.collection.user = @request.auth.id"); + expect(migration).toContain("list.user = @request.auth.id"); + expect(migration).toContain("collection.user = @request.auth.id"); + expect(migration).toContain("@request.body.user:changed = false"); + }); + + test("notifications are owner-readable and backend-writable only", async () => { + const migration = await readMigration("1785000007_secure_notification_collection_rules.js"); + + expect(migration).not.toContain('"listRule": ""'); + expect(migration).toContain("topicId.userId = @request.auth.id"); + expect(migration).toContain("userId = @request.auth.id"); + expect(migration.match(/"createRule": null/g)).toHaveLength(4); + expect(migration.match(/"updateRule": null/g)).toHaveLength(4); + expect(migration.match(/"deleteRule": null/g)).toHaveLength(4); + }); +}); diff --git a/apps/backend/src/lib/data/sessions.ts b/apps/backend/src/lib/data/sessions.ts index 07492549..71f614c5 100644 --- a/apps/backend/src/lib/data/sessions.ts +++ b/apps/backend/src/lib/data/sessions.ts @@ -1,4 +1,5 @@ import type { RecordModel } from "pocketbase"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; import { ApiActionError } from "./auth"; @@ -6,6 +7,10 @@ export type SessionRecord = { id: string; user: string; sessionId: string; + tokenHash: string; + pocketbaseToken?: string; + expiresAt: string; + revokedAt?: string; displayName: string; clientType?: string; platform?: string; @@ -55,6 +60,33 @@ function toSessionRecord(record: RecordModel) { return record as unknown as SessionRecord; } +const SESSION_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000; + +export function hashSessionToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +export async function createSession( + pb: { collection: (name: "sessions") => any; authStore?: { token?: string } }, + userId: string, + metadata?: SessionMetadata, +) { + const token = `dws_${randomBytes(32).toString("base64url")}`; + const now = new Date(); + const sessionId = randomUUID().replace(/-/g, ""); + const record = await pb.collection("sessions").create({ + user: userId, + sessionId, + tokenHash: hashSessionToken(token), + pocketbaseToken: pb.authStore?.token ?? "", + displayName: DEFAULT_DISPLAY_NAME, + lastSeenAt: now.toISOString(), + expiresAt: new Date(now.getTime() + SESSION_LIFETIME_MS).toISOString(), + ...normalizeMetadata(metadata), + }); + return { token, session: toSessionRecord(record) }; +} + export async function ensureSession( pb: { collection: (name: "sessions") => any }, userId: string, @@ -124,6 +156,18 @@ export async function getCurrentSession( return session; } +export async function listSessions(pb: { collection: (name: "sessions") => any }, userId: string) { + return (await pb.collection("sessions").getFullList({ sort: "-lastSeenAt" })) + .filter((session: RecordModel) => session.user === userId).map(toSessionRecord); +} + +export async function revokeSession(pb: { collection: (name: "sessions") => any }, userId: string, rawSessionId: unknown) { + const session = await getSessionById(pb, userId, rawSessionId); + if (!session) throw new ApiActionError("Session not found", 404, { error: "Session not found" }); + await pb.collection("sessions").delete(session.id); + return { success: true }; +} + export async function renameCurrentSession( pb: { collection: (name: "sessions") => any }, userId: string, diff --git a/apps/backend/src/lib/device-code.ts b/apps/backend/src/lib/device-code.ts new file mode 100644 index 00000000..841f9950 --- /dev/null +++ b/apps/backend/src/lib/device-code.ts @@ -0,0 +1,126 @@ +import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; + +import { ApiActionError } from "./data/auth"; +import { createSession } from "./data/sessions"; +import { config } from "./config"; + +type DeviceSocket = { send: (data: string) => void; close?: (code?: number, reason?: string) => void }; + +type PendingDeviceCode = { + id: string; + userId: string | null; + code: string; + secret: string; + createdAt: string; + expiresAt: number; + ip: string; + userAgent: string; + socket: DeviceSocket; + consumed: boolean; + socketAuthenticated: boolean; +}; + +const pending = new Map(); +const rate = new Map(); +const auditEvents: Array> = []; +const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +function purge() { + const now = Date.now(); + for (const [id, request] of pending) { + if (request.expiresAt <= now || request.consumed) pending.delete(id); + } + for (const [key, timestamps] of rate) { + const current = timestamps.filter((time) => time > now - 60_000); + if (current.length) rate.set(key, current); else rate.delete(key); + } +} + +function limited(key: string, maximum: number) { + purge(); + const timestamps = rate.get(key) ?? []; + if (timestamps.length >= maximum) return true; + timestamps.push(Date.now()); + rate.set(key, timestamps); + return false; +} + +function randomCode() { + let code = ""; + for (let i = 0; i < 6; i++) code += codeAlphabet[randomBytes(1)[0] % codeAlphabet.length]; + return `${code.slice(0, 3)}-${code.slice(3)}`; +} + +function sameSecret(a: string, b: string) { + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && timingSafeEqual(left, right); +} + +export function createDeviceRequest(socket: DeviceSocket, ip: string, userAgent: string) { + purge(); + const activeSockets = [...pending.values()].filter((request) => !request.consumed).length; + if (pending.size >= config.AUTH_DEVICE_CODE_MAX_PENDING || + activeSockets >= config.AUTH_DEVICE_CODE_MAX_SOCKETS) { + throw new ApiActionError("Device login is temporarily unavailable", 429, { error: "Device login is temporarily unavailable" }); + } + if (limited(`create:${ip}`, 10)) { + throw new ApiActionError("Too many device login requests", 429, { error: "Too many device login requests" }); + } + let code = randomCode(); + while (pending.has(code)) code = randomCode(); + const request: PendingDeviceCode = { + id: randomUUID(), userId: null, code, secret: randomBytes(32).toString("base64url"), + createdAt: new Date().toISOString(), expiresAt: Date.now() + config.AUTH_DEVICE_CODE_TTL_MS, + ip: ip || "unknown", userAgent: userAgent.slice(0, 300), socket, consumed: false, socketAuthenticated: false, + }; + pending.set(request.id, request); + return { id: request.id, code: request.code, secret: request.secret, expiresAt: new Date(request.expiresAt).toISOString() }; +} + +function invalidCode() { + return new ApiActionError("Invalid or expired device code", 400, { error: "Invalid or expired device code" }); +} + +export function findDeviceRequest(rawCode: unknown, ip: string) { + const code = typeof rawCode === "string" ? rawCode.trim().toUpperCase() : ""; + if (!/^[A-Z0-9]{3}-[A-Z0-9]{3}$/.test(code) || limited(`entry:${ip}`, 20)) throw invalidCode(); + purge(); + const request = [...pending.values()].find((item) => item.code === code && !item.consumed); + if (!request || request.expiresAt <= Date.now()) throw invalidCode(); + return { id: request.id, code: request.code, createdAt: request.createdAt, expiresAt: new Date(request.expiresAt).toISOString(), ip: request.ip, userAgent: request.userAgent }; +} + +export async function approveDeviceRequest(rawCode: unknown, approver: { pb: any; userId: string; sessionId: string | null }, ip: string) { + const viewed = findDeviceRequest(rawCode, ip); + const request = pending.get(viewed.id); + if (!request || request.consumed || !request.socketAuthenticated) throw invalidCode(); + if (request.userId && request.userId !== approver.userId) throw invalidCode(); + request.userId = approver.userId; + request.consumed = true; + const authModel = await approver.pb.collection("users").authRefresh(); + const { token, session } = await createSession(approver.pb, approver.userId, { clientType: "device-code", platform: request.userAgent }); + const user = { ...authModel.record }; + delete user.totpSecret; + request.socket.send(JSON.stringify({ type: "approved", token, user, sessionId: session.sessionId })); + auditEvents.push({ type: "device_login_approved", userId: approver.userId, approvingSessionId: approver.sessionId, createdSessionId: session.sessionId, createdAt: new Date().toISOString() }); + request.socket.close?.(1000, "Approved"); + return { success: true }; +} + +export function cancelDeviceRequest(rawCode: unknown, ip: string) { + const viewed = findDeviceRequest(rawCode, ip); + const request = pending.get(viewed.id); + if (request) { request.consumed = true; request.socket.send(JSON.stringify({ type: "cancelled" })); request.socket.close?.(1000, "Cancelled"); } + return { success: true }; +} + +export function authenticateDeviceSocket(id: string, secret: string) { + const request = pending.get(id); + if (!request || request.consumed || request.expiresAt <= Date.now() || !sameSecret(request.secret, secret)) return false; + request.socketAuthenticated = true; + return true; +} + +export function getDeviceAuditEvents() { return auditEvents.slice(-100); } +export function closeDeviceRequest(id: string) { const request = pending.get(id); if (request) { request.consumed = true; pending.delete(id); } } diff --git a/apps/backend/src/routes/auth.route.ts b/apps/backend/src/routes/auth.route.ts index 7dd61134..78b969a9 100644 --- a/apps/backend/src/routes/auth.route.ts +++ b/apps/backend/src/routes/auth.route.ts @@ -10,11 +10,15 @@ import { } from "../lib/data/auth"; import type { UserPropertyValue } from "../lib/data/auth"; import { ensureBuiltinIntegrations } from "../lib/data/integrations"; +import { revokeSession } from "../lib/data/sessions"; import { getSuperuserPB } from "../lib/pb/pocketbase"; +import { approveDeviceRequest, cancelDeviceRequest, findDeviceRequest } from "../lib/device-code"; import { loadSignupDefaults, readAuthToken, + readAuth, + requireAuth, readJsonBody, routeRedirectTarget, withJson, @@ -53,6 +57,32 @@ authRoute.get( }); }), ) + .post("/api/v1/auth/logout", withJson(async (c) => { + const auth = await requireAuth(readAuth(c)); + return revokeSession(auth.pb, auth.userId, auth.sessionId); + })) + .post( + "/api/v1/auth/device-code/lookup", + withJson(async (c) => { + await requireAuth(readAuth(c)); + return findDeviceRequest((await readJsonBody<{ code?: string }>(c)).code, c.req.header("x-forwarded-for")?.split(",")[0] ?? "unknown"); + }), + ) + .post( + "/api/v1/auth/device-code/approve", + withJson(async (c) => { + const requestAuth = readAuth(c); + const auth = await requireAuth(requestAuth); + return approveDeviceRequest((await readJsonBody<{ code?: string }>(c)).code, { ...auth, sessionId: requestAuth.sessionId }, c.req.header("x-forwarded-for")?.split(",")[0] ?? "unknown"); + }), + ) + .post( + "/api/v1/auth/device-code/cancel", + withJson(async (c) => { + await requireAuth(readAuth(c)); + return cancelDeviceRequest((await readJsonBody<{ code?: string }>(c)).code, c.req.header("x-forwarded-for")?.split(",")[0] ?? "unknown"); + }), + ) .post( "/api/v1/auth/signup", withJson(async (c) => { diff --git a/apps/backend/src/routes/links.route.ts b/apps/backend/src/routes/links.route.ts index 797336ff..cedd83a8 100644 --- a/apps/backend/src/routes/links.route.ts +++ b/apps/backend/src/routes/links.route.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; -import { createCollection, createCollectionLinkItem, createHomeLinkGroup, createHomeLinkItem, createLinkTag, createLinksFolder, deleteLinkItem, getHomeLinkGroups, getHomeLinks, getLinksCollections, getLinksFolders, getLinksItems, getLinksTags, reorderLinks, updateCollection, updateHomeLinkFolderIcon, updateHomeLinkItem, updateLinkTag, wipeUserLinks } from "../lib/data/links"; +import { createCollection, createCollectionLinkItem, createHomeLinkGroup, createHomeLinkItem, createLinkTag, createLinksFolder, deleteLinkItem, deleteUnusedHomeLinkGroups, getHomeLinkGroups, getHomeLinks, getLinksCollections, getLinksFolders, getLinksItems, getLinksTags, reorderLinks, updateCollection, updateHomeLinkFolderIcon, updateHomeLinkItem, updateLinkTag, wipeUserLinks } from "../lib/data/links"; import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; import { config } from "../lib/config"; @@ -31,6 +31,10 @@ linksRoute const { userId } = await requireAuth({ token: readAuthToken(c) }); return updateCollection(userId, String(c.req.param("collectionId") ?? ""), body?.data ?? {}); })) + .post("/api/v1/links/home/groups/cleanup", withJson(async (c) => { + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return deleteUnusedHomeLinkGroups(userId); + })) .get("/api/v1/links/home/groups", withJson(async (c) => { const { userId } = await requireAuth({ token: readAuthToken(c) }); return getHomeLinkGroups(userId); @@ -101,8 +105,7 @@ linksRoute })) .get("/api/v1/links/tags", withJson(async (c) => { const { userId } = await requireAuth({ token: readAuthToken(c) }); - void userId; - return getLinksTags(); + return getLinksTags(userId); })) .post("/api/v1/links/tags", withJson(async (c) => { const body = await readJsonBody(c); diff --git a/apps/backend/src/routes/sessions.route.ts b/apps/backend/src/routes/sessions.route.ts index 948c179d..de31e37c 100644 --- a/apps/backend/src/routes/sessions.route.ts +++ b/apps/backend/src/routes/sessions.route.ts @@ -1,15 +1,23 @@ import { Hono } from "hono"; -import { getCurrentSession, renameCurrentSession } from "../lib/data/sessions"; +import { getCurrentSession, listSessions, renameCurrentSession, revokeSession } from "../lib/data/sessions"; import { readAuth, readJsonBody, readSessionMetadata, requireAuth, withJson } from "./shared"; const sessionsRoute = new Hono(); sessionsRoute + .get("/api/v1/sessions", withJson(async (c) => { + const auth = await requireAuth(readAuth(c)); + return listSessions(auth.pb, auth.userId); + })) + .delete("/api/v1/sessions/:sessionId", withJson(async (c) => { + const auth = await requireAuth(readAuth(c)); + return revokeSession(auth.pb, auth.userId, c.req.param("sessionId")); + })) .get("/api/v1/sessions/current", withJson(async (c) => { const requestAuth = readAuth(c); const auth = await requireAuth(requestAuth); - return getCurrentSession(auth.pb, auth.userId, requestAuth.sessionId, readSessionMetadata(c)); + return getCurrentSession(auth.pb, auth.userId, auth.sessionId, readSessionMetadata(c)); })) .patch("/api/v1/sessions/current", withJson(async (c) => { const body = await readJsonBody<{ displayName?: unknown }>(c); @@ -18,7 +26,7 @@ sessionsRoute return renameCurrentSession( auth.pb, auth.userId, - requestAuth.sessionId, + auth.sessionId, body.displayName, readSessionMetadata(c), ); diff --git a/apps/backend/src/routes/shared.ts b/apps/backend/src/routes/shared.ts index ab534d2c..422e3b54 100644 --- a/apps/backend/src/routes/shared.ts +++ b/apps/backend/src/routes/shared.ts @@ -106,7 +106,7 @@ export function readAuthToken(c: Context) { } } - return c.req.query("token") ?? c.req.query("authToken") ?? null; + return null; } export function readAuth(c: Context) { diff --git a/apps/web/public/openapi.json b/apps/web/public/openapi.json index ea450337..5e66d3eb 100644 --- a/apps/web/public/openapi.json +++ b/apps/web/public/openapi.json @@ -481,6 +481,19 @@ } } }, + "/links/home/groups/cleanup": { + "post": { + "tags": [ + "links" + ], + "summary": "Delete unused home link groups", + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + } + }, "/links/home/groups": { "get": { "tags": [ diff --git a/apps/web/src/app/(authenticated)/settings/account/page.tsx b/apps/web/src/app/(authenticated)/settings/account/page.tsx index 610f827f..296bd9d7 100644 --- a/apps/web/src/app/(authenticated)/settings/account/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/account/page.tsx @@ -17,7 +17,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { ChangePasswordRequest } from '@/lib/apiClient'; import { useNavigate } from "react-router-dom"; import { changePasswordAction, deleteAccountAction } from '@/lib/apiClient'; -import { getCurrentSessionAction, renameCurrentSessionAction } from '@/lib/apiClient'; +import { approveDeviceCodeAction, cancelDeviceCodeAction, getCurrentSessionAction, listSessionsAction, lookupDeviceCodeAction, renameCurrentSessionAction, revokeSessionAction } from '@/lib/apiClient'; import { DialogDescription } from "@radix-ui/react-dialog"; import useAuth from "@/context/useAuth"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -41,7 +41,13 @@ export default function AccountSettingsPage() { const [sessionName, setSessionName] = useState(""); const [sessionError, setSessionError] = useState(null); const [isDeviceNameDialogOpen, setIsDeviceNameDialogOpen] = useState(false); + const [isDeviceCodeDialogOpen, setIsDeviceCodeDialogOpen] = useState(false); + const [deviceCode, setDeviceCode] = useState(""); + const [deviceRequest, setDeviceRequest] = useState> | null>(null); + const [deviceCodeError, setDeviceCodeError] = useState(null); + const [deviceCodeLoading, setDeviceCodeLoading] = useState(false); const queryClient = useQueryClient(); + const sessionsQuery = useQuery({ queryKey: ["auth", "sessions", token], enabled: Boolean(token), queryFn: () => withAuth(listSessionsAction) }); const sessionQuery = useQuery({ queryKey: queryKeys.auth.session(token), enabled: Boolean(token), @@ -79,6 +85,22 @@ export default function AccountSettingsPage() { const needsDeviceName = !normalizedSessionDisplayName || DEFAULT_SESSION_NAMES.has(normalizedSessionDisplayName); + const handleDeviceCodeSubmit = async (event: React.FormEvent) => { + event.preventDefault(); setDeviceCodeError(null); setDeviceCodeLoading(true); + try { setDeviceRequest(await lookupDeviceCodeAction({ token }, deviceCode)); } + catch (cause: any) { setDeviceCodeError(cause?.message ?? "Invalid or expired device code"); } + finally { setDeviceCodeLoading(false); } + }; + const approveDeviceCode = async () => { + setDeviceCodeLoading(true); setDeviceCodeError(null); + try { await approveDeviceCodeAction({ token }, deviceCode); setIsDeviceCodeDialogOpen(false); setDeviceCode(""); setDeviceRequest(null); } + catch (cause: any) { setDeviceCodeError(cause?.message ?? "Unable to approve device"); } + finally { setDeviceCodeLoading(false); } + }; + const cancelDeviceCode = async () => { + try { await cancelDeviceCodeAction({ token }, deviceCode); setIsDeviceCodeDialogOpen(false); setDeviceCode(""); setDeviceRequest(null); } catch (cause: any) { setDeviceCodeError(cause?.message ?? "Unable to cancel device"); } + }; + const handleChangePasswordSubmit = async ( e: React.FormEvent, ) => { @@ -153,7 +175,7 @@ export default function AccountSettingsPage() { } const payload: { email: string; password: string; totp?: string } = { - email: user.email ?? user.username ?? "", + email: String(user.email ?? user.username ?? ""), password: deletePassword, }; @@ -188,6 +210,7 @@ export default function AccountSettingsPage() {

Authentication

+

Active sessions

{sessionsQuery.data?.map((session) =>
{session.displayName} · last used {new Date(session.lastSeenAt).toLocaleString()}
)}{sessionsQuery.data?.length === 0 &&

No active sessions found.

}
+ { setIsDeviceCodeDialogOpen(open); if (!open) { setDeviceRequest(null); setDeviceCodeError(null); } }}> + +

Authenticate another session using Device Code

+
+ Authenticate another session using Device CodeEnter the code shown on the other browser. Verify its details before approving. +
setDeviceCode(e.target.value.toUpperCase())} placeholder="ABC-DEF" maxLength={7} disabled={deviceCodeLoading || !!deviceRequest} /> + {deviceRequest &&

Requested: {new Date(deviceRequest.createdAt).toLocaleString()}

Browser: {deviceRequest.userAgent}

IP: {deviceRequest.ip}

}{deviceCodeError &&

{deviceCodeError}

}
{deviceRequest ? <> : <>}
+
+
+ diff --git a/apps/web/src/app/(authenticated)/settings/general/page.tsx b/apps/web/src/app/(authenticated)/settings/general/page.tsx index 5154cb20..a2327d76 100644 --- a/apps/web/src/app/(authenticated)/settings/general/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/general/page.tsx @@ -17,7 +17,7 @@ import { } from "@/components/ui/alert-dialog"; import LocationSelectFormComponent from "@/components/settings/LocationSelectForm"; import useAuth from "@/context/useAuth"; -import { runPullIconsAction, wipeUserLinksAction } from '@/lib/apiClient'; +import { deleteUnusedHomeLinkGroupsAction, runPullIconsAction, wipeUserLinksAction } from '@/lib/apiClient'; import { useLocalization } from "@/context/LocalizationContext"; type TimeFormatValue = "24-hour" | "12-hour"; @@ -50,6 +50,8 @@ export default function GeneralSettingsPage() { const [isRefreshingIcons, setIsRefreshingIcons] = useState(false); const [isWipingUserLinks, setIsWipingUserLinks] = useState(false); const [wipeResult, setWipeResult] = useState(null); + const [isDeletingUnusedGroups, setIsDeletingUnusedGroups] = useState(false); + const [unusedGroupsResult, setUnusedGroupsResult] = useState(null); const { token, withAuth } = useAuth(); async function handleRefreshIcons() { @@ -65,6 +67,23 @@ export default function GeneralSettingsPage() { } } + async function handleDeleteUnusedGroups() { + if (isDeletingUnusedGroups) return; + + try { + setIsDeletingUnusedGroups(true); + setUnusedGroupsResult(null); + const result = await withAuth((auth) => deleteUnusedHomeLinkGroupsAction(auth)); + const count = result.deletedGroups.length; + setUnusedGroupsResult(count ? `Deleted ${count} unused link group${count === 1 ? "" : "s"}.` : "No unused link groups found."); + } catch (error) { + console.error("Failed to delete unused link groups", error); + setUnusedGroupsResult(error instanceof Error ? error.message : "Failed to delete unused link groups."); + } finally { + setIsDeletingUnusedGroups(false); + } + } + async function handleWipeUserLinks() { if (isWipingUserLinks) return; @@ -148,6 +167,34 @@ export default function GeneralSettingsPage() { + + + +
+

Delete unused link groups

+ {unusedGroupsResult &&

{unusedGroupsResult}

} +
+ +
+ + + Delete unused link groups? + + This permanently deletes empty link groups and their empty nested folders. Groups containing links will be kept. + + + + Cancel + void handleDeleteUnusedGroups()} + disabled={isDeletingUnusedGroups} + > + {isDeletingUnusedGroups ? "Deleting groups..." : "Delete unused groups"} + + + +

Weather

(null) const [success, setSuccess] = useState(null) + const [deviceCode, setDeviceCode] = useState(null) + const deviceSocket = useRef(null) const appConfigQuery = useQuery({ queryKey: queryKeys.appConfig, queryFn: getAppConfigAction }); const enableSSO = appConfigQuery.data?.enableSSO ?? false; const loginMutation = useMutation({ mutationFn: loginUserAction }); + useEffect(() => { + const socket = new WebSocket(deviceCodeSocketUrl()); + deviceSocket.current = socket; + socket.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.type === "device-code") { setDeviceCode(message.code); socket.send(JSON.stringify({ type: "authenticate", requestId: message.requestId, secret: message.secret })); } + if (message.type === "approved") { setClientSessionId(message.sessionId); setAuth(message.user, message.token); setSuccess("Device approved! Redirecting..."); setTimeout(() => navigate("/home"), 500); } + if (message.type === "error") setError(message.message); + }; + socket.onerror = () => setError("Unable to start device login"); + return () => { socket.close(); deviceSocket.current = null; }; + }, [navigate, setAuth]); + //on load: check for existing auth, validate using /api/v1/auth/validate-auth endpoint if returned success to /home useEffect(() => { @@ -61,7 +77,8 @@ export default function LoginCard() { setError(null); setSuccess(null); try { - const { token: newToken, user } = await loginMutation.mutateAsync({ email, password }) as { token: string; user: import("@dashwise/types/sdk").AuthUserRecord }; + const { token: newToken, sessionId, user } = await loginMutation.mutateAsync({ email, password }) as { token: string; sessionId: string; user: import("@dashwise/types/sdk").AuthUserRecord }; + setClientSessionId(sessionId); setAuth(user, newToken); setSuccess("Login successful! Redirecting to home..."); @@ -153,6 +170,7 @@ export default function LoginCard() { className="frosted" required /> +

Or login using Device Code {deviceCode ?? "connecting..."}