The apps you pin to your dock are kept with your account, per workspace and in your order, so the dock is the same on every device: identity keeps them in dock_pins and answers dock_pins and set_dock_pins, the dock reads them with the rest of the page, pins kept only on this device carry over with your first change, this device's copy still draws the dock when identity can't be reached, a pin that can't be saved says so, and they go when you or the workspace do; the workspaces guide says how.
14 files+340−220/14 viewed
| 574 | 574 | 2. Find the app, by name if you like. | |
| 575 | 575 | 3. Select the pin on its tile. Select it again to unpin it. | |
| 576 | 576 | ||
| 577 | − | Your pins are yours alone, kept per workspace in this browser, so another | |
| 578 | − | browser or device starts with none. There is no marketplace yet: it shows | |
| 579 | − | as coming. | |
| 577 | + | Your pins are yours alone and each workspace has its own. They are saved | |
| 578 | + | to your account, so your dock is the same on every browser and device you | |
| 579 | + | sign in on, in the order you pinned. There is no marketplace yet: it | |
| 580 | + | shows as coming. | |
| 580 | 581 | ||
| 581 | 582 | ### The sidebar | |
| 582 | 583 |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { APPS, MAX_APP_PINS, appPinFromForm, appsFor, dockCookie, isPinnable, pinsIn, readDock, sidebarClosed, sidebarCookie, withPin, writeDock } from "./apps.ts"; | |
| 4 | + | import { APPS, MAX_APP_PINS, appPinFromForm, appsFor, dockCookie, isPinnable, pinsFrom, pinsIn, pinsToShow, readDock, sidebarClosed, sidebarCookie, withPin, writeDock } from "./apps.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("built-in apps are never pinned; the rest are", () => { | |
| 7 | 7 | for (const app of APPS) assert.equal(isPinnable(app.key), !app.builtin, app.key); | |
| ⋯ | |||
| 32 | 32 | assert.ok(pinsIn(`acme:${many}`, "acme").length <= MAX_APP_PINS); | |
| 33 | 33 | }); | |
| 34 | 34 | ||
| 35 | + | test("pins kept with the account keep their order and only real apps", () => { | |
| 36 | + | assert.deepEqual(pinsFrom(["usage", "projects", "today", "retired-app", "usage", "teams"]), ["usage", "projects", "teams"]); | |
| 37 | + | assert.deepEqual(pinsFrom([]), []); | |
| 38 | + | assert.ok(pinsFrom(Array.from({ length: 30 }, (_, n) => APPS[8 + (n % 10)]!.key)).length <= MAX_APP_PINS); | |
| 39 | + | }); | |
| 40 | + | ||
| 41 | + | test("the account's pins win over the cookie, which is the fallback", () => { | |
| 42 | + | const cookie = writeDock(null, "acme", ["projects", "usage"]); | |
| 43 | + | // Saved with the account, even with nothing pinned: the account's. | |
| 44 | + | assert.deepEqual(pinsToShow(["teams", "audit"], cookie, "acme"), ["teams", "audit"]); | |
| 45 | + | assert.deepEqual(pinsToShow([], cookie, "acme"), []); | |
| 46 | + | // Never saved, or identity could not be asked: this device's cookie. | |
| 47 | + | assert.deepEqual(pinsToShow(null, cookie, "acme"), ["projects", "usage"]); | |
| 48 | + | assert.deepEqual(pinsToShow(undefined, null, "acme"), []); | |
| 49 | + | }); | |
| 50 | + | ||
| 35 | 51 | test("a pin form pins or unpins one app", () => { | |
| 36 | 52 | const form = (fields: Record<string, string>) => { | |
| 37 | 53 | const data = new FormData(); | |
| 8 | 8 | * and the audit log. Each person pins the ones they want, and nobody sees | |
| 9 | 9 | * an app they cannot use: Code's apps are for members with Code access. | |
| 10 | 10 | * | |
| 11 | − | * Pins are each person's own, per workspace, and kept in a cookie | |
| 12 | − | * (`g1t_dock`) so the page is drawn with the right dock from the server. | |
| 11 | + | * Pins are each person's own, per workspace, in the order they set, and | |
| 12 | + | * kept with their account by the identity service (`dock_pins`), so the | |
| 13 | + | * dock is the same on every device (lib/dock.server.ts). A cookie | |
| 14 | + | * (`g1t_dock`) keeps a copy on each device: the dock still draws from it | |
| 15 | + | * when identity cannot be reached, and pins from before they were kept | |
| 16 | + | * with the account carry over from it until the first change. | |
| 13 | 17 | * No Workers or React imports, so it can be tested under Node. | |
| 14 | 18 | */ | |
| 15 | 19 | ||
| ⋯ | |||
| 85 | 89 | return APPS.find((app) => app.key === key)!; | |
| 86 | 90 | } | |
| 87 | 91 | ||
| 88 | − | /** The cookie that keeps each person's pins, per workspace. */ | |
| 92 | + | /** The cookie that keeps this device's copy of each person's pins, per workspace. */ | |
| 89 | 93 | export const DOCK_COOKIE = "g1t_dock"; | |
| 90 | 94 | ||
| 91 | − | /** Most workspaces the cookie remembers pins for, and most pins in each. */ | |
| 95 | + | /** | |
| 96 | + | * Most workspaces the cookie remembers pins for, and most pins in each. | |
| 97 | + | * Identity keeps up to 24 (`MAX_DOCK_PINS`); this stays under it. | |
| 98 | + | */ | |
| 92 | 99 | const MAX_WORKSPACES = 20; | |
| 93 | 100 | export const MAX_APP_PINS = 12; | |
| 94 | 101 | ||
| 102 | + | /** | |
| 103 | + | * Pins as identity keeps them, read against the apps there are: keys that | |
| 104 | + | * are not an app people pin (one since retired, say) are dropped, repeats | |
| 105 | + | * too, in the order they were set. | |
| 106 | + | */ | |
| 107 | + | export function pinsFrom(keys: readonly string[]): PinnableApp[] { | |
| 108 | + | return [...new Set(keys.filter(isPinnable))].slice(0, MAX_APP_PINS); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /** | |
| 112 | + | * The pins to draw in the workspace `slug`: the account's when identity | |
| 113 | + | * has them (`stored`), else this device's cookie, for pins never saved to | |
| 114 | + | * the account or when identity could not be asked. | |
| 115 | + | */ | |
| 116 | + | export function pinsToShow(stored: readonly string[] | null | undefined, cookie: string | null | undefined, slug: string): PinnableApp[] { | |
| 117 | + | return stored ? pinsFrom(stored) : pinsIn(cookie, slug); | |
| 118 | + | } | |
| 119 | + | ||
| 95 | 120 | /** Every workspace's pins, from the cookie's value: `acme:projects.usage,beta:teams`. */ | |
| 96 | 121 | export function readDock(value: string | null | undefined): Record<string, PinnableApp[]> { | |
| 97 | 122 | const dock: Record<string, PinnableApp[]> = {}; | |
| 1 | + | /** | |
| 2 | + | * Each person's dock pins, kept with their account by identity | |
| 3 | + | * (`dock_pins` / `set_dock_pins`), so their dock follows them to every | |
| 4 | + | * device. lib/apps.ts says which apps can be pinned; identity only keeps | |
| 5 | + | * the list in order. | |
| 6 | + | */ | |
| 7 | + | import type { User } from "@g1t/contracts"; | |
| 8 | + | ||
| 9 | + | import { type PinnableApp, pinsFrom } from "./apps"; | |
| 10 | + | import { identity } from "./services.server"; | |
| 11 | + | ||
| 12 | + | /** | |
| 13 | + | * The pins `user` saved in the workspace `slug`, or null when they never | |
| 14 | + | * saved any there or identity could not be asked: the caller falls back | |
| 15 | + | * to the cookie (lib/apps.ts `pinsToShow`). | |
| 16 | + | */ | |
| 17 | + | export async function savedPins(user: User, slug: string): Promise<PinnableApp[] | null> { | |
| 18 | + | try { | |
| 19 | + | const stored = await identity.dockPins(user, slug); | |
| 20 | + | return stored ? pinsFrom(stored) : null; | |
| 21 | + | } catch (error) { | |
| 22 | + | console.error("dock: pins could not be read", error); | |
| 23 | + | return null; | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /** Saves `pins` as `user`'s in `slug`, in their order; the pins as kept, or null when they could not be saved. */ | |
| 28 | + | export async function savePins(user: User, slug: string, pins: PinnableApp[]): Promise<PinnableApp[] | null> { | |
| 29 | + | try { | |
| 30 | + | const result = await identity.setDockPins(user, slug, pins); | |
| 31 | + | return result.ok ? pinsFrom(result.value) : null; | |
| 32 | + | } catch (error) { | |
| 33 | + | console.error("dock: pins could not be saved", error); | |
| 34 | + | return null; | |
| 35 | + | } | |
| 36 | + | } |
| 34 | 34 | import { PageMain } from "./components/landmark"; | |
| 35 | 35 | import { NotFound } from "./components/not-found"; | |
| 36 | 36 | import { frameOf } from "./lib/chrome"; | |
| 37 | − | import { DOCK_COOKIE, SIDEBAR_COOKIE, pinsIn, sidebarClosed } from "./lib/apps"; | |
| 37 | + | import { DOCK_COOKIE, SIDEBAR_COOKIE, pinsToShow, sidebarClosed } from "./lib/apps"; | |
| 38 | + | import { savedPins } from "./lib/dock.server"; | |
| 38 | 39 | import { StandaloneFrame } from "./components/standalone"; | |
| 39 | 40 | import { billing, chat, inbox, projects, workspaceAgents } from "./lib/services.server"; | |
| 40 | 41 | import { unreadTotals } from "./lib/chat"; | |
| ⋯ | |||
| 90 | 91 | : visitorShell(params, context), | |
| 91 | 92 | registrationMode(), | |
| 92 | 93 | ]); | |
| 93 | − | // The apps this person pinned to their dock here, from their cookie (lib/apps.ts). | |
| 94 | − | if (shell.workspace) shell.pins = pinsIn(dock, shell.workspace.slug); | |
| 94 | + | // The apps this person pinned to their dock here: as their account keeps | |
| 95 | + | // them (read with the rest of the shell), else as this device's cookie | |
| 96 | + | // remembers them (lib/apps.ts). | |
| 97 | + | if (shell.workspace) shell.pins = pinsToShow(shell.pins, dock, shell.workspace.slug); | |
| 95 | 98 | // Where this g1t lives, for clone lines, agent setup and link previews. | |
| 96 | 99 | return { | |
| 97 | 100 | user, | |
| ⋯ | |||
| 168 | 171 | // something (lib/cache.server.ts). | |
| 169 | 172 | const kept = <T,>(what: string, load: () => Promise<T>) => | |
| 170 | 173 | workspace ? shortCache(`shell:${what}:${user.id}:${workspace.slug}`, SHELL_TTL_MS, load) : Promise.resolve(null); | |
| 171 | − | const [listed, counts, status, usage, limit, entitlements, shared, unread, shortcuts, chatUnread, teamAgents] = await Promise.all([ | |
| 174 | + | const [listed, counts, status, usage, limit, entitlements, shared, unread, shortcuts, chatUnread, teamAgents, dockPins] = await Promise.all([ | |
| 172 | 175 | // Every project, for the palette and the count; the sidebar lists only | |
| 173 | 176 | // the person's pinned and recent ones (lib/pins.ts). | |
| 174 | 177 | workspace ? workspaceProjects(workspace.slug, user) : Promise.resolve(null), | |
| ⋯ | |||
| 188 | 191 | workspace ? chatUnreadFor(workspace.slug, user) : Promise.resolve(null), | |
| 189 | 192 | // Home's Recent and the Agents sidebar, on every page: never waited on for long. | |
| 190 | 193 | workspace ? agentsFor(workspace.slug, user) : Promise.resolve(null), | |
| 194 | + | // The dock's pins, kept with the account so every device shows the same | |
| 195 | + | // dock; never kept here, so a pin shows the moment it is made. Null | |
| 196 | + | // falls back to the cookie (lib/dock.server.ts). | |
| 197 | + | workspace ? savedPins(user, workspace.slug) : Promise.resolve(null), | |
| 191 | 198 | ]); | |
| 192 | 199 | return { | |
| 193 | 200 | workspace, | |
| ⋯ | |||
| 225 | 232 | inbox: unread, | |
| 226 | 233 | chat: chatUnread, | |
| 227 | 234 | agents: teamAgents, | |
| 235 | + | pins: dockPins ?? undefined, | |
| 228 | 236 | // While g1t is free every charge is zero, so usage is shown at cost. | |
| 229 | 237 | // Usage at price, the one figure every page shows. | |
| 230 | 238 | monthUsageMicros: usage?.ok ? (usage.value.free ? usage.value.usedMicros : (usage.value.priceMicros ?? usage.value.spentMicros)) : null, | |
| 2 | 2 | * Apps: every app in the workspace that you can use, the ones pinned to | |
| 3 | 3 | * your dock first. Built-in apps are always in the dock; the rest you pin | |
| 4 | 4 | * or unpin here, from the launcher, or on a phone from More. Pins are | |
| 5 | − | * yours alone, per workspace, kept in a cookie (lib/apps.ts) so the dock | |
| 6 | − | * is drawn right from the server. The pin buttons post here. | |
| 5 | + | * yours alone, per workspace, kept with your account so every device | |
| 6 | + | * shows the same dock (lib/dock.server.ts), with a copy in a cookie for | |
| 7 | + | * when the account's cannot be read (lib/apps.ts). The pin buttons post | |
| 8 | + | * here. | |
| 7 | 9 | */ | |
| 8 | 10 | import { Store } from "lucide-react"; | |
| 9 | 11 | import { data } from "react-router"; | |
| ⋯ | |||
| 12 | 14 | ||
| 13 | 15 | import type { Route } from "./+types/apps"; | |
| 14 | 16 | import { AppTile, useAppPins } from "../../components/apps"; | |
| 15 | − | import { DOCK_COOKIE, type PinnableApp, appPinFromForm, appsFor, dockCookie, pinsIn, withPin, writeDock } from "../../lib/apps"; | |
| 17 | + | import { DOCK_COOKIE, type PinnableApp, appPinFromForm, appsFor, dockCookie, pinsToShow, withPin, writeDock } from "../../lib/apps"; | |
| 18 | + | import { savePins, savedPins } from "../../lib/dock.server"; | |
| 16 | 19 | import { page } from "../../lib/meta"; | |
| 17 | 20 | import { readCookie } from "../../lib/mission"; | |
| 18 | 21 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| ⋯ | |||
| 21 | 24 | return page(args, { title: `Apps · ${params.owner} · g1t` }); | |
| 22 | 25 | } | |
| 23 | 26 | ||
| 24 | − | export function loader({ params, context, request }: Route.LoaderArgs) { | |
| 27 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 25 | 28 | const viewer = getViewer(context); | |
| 26 | 29 | const slug = params.owner.toLowerCase(); | |
| 27 | 30 | const membership = viewer?.workspaces?.find((m) => m.slug === slug); | |
| 28 | − | if (!membership) throw data(null, { status: 404 }); | |
| 31 | + | if (!viewer || !membership) throw data(null, { status: 404 }); | |
| 29 | 32 | return { | |
| 30 | 33 | slug, | |
| 31 | 34 | name: membership.name?.trim() || slug, | |
| 32 | 35 | code: hasCodeAccess(membership), | |
| 33 | − | pins: pinsIn(readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug), | |
| 36 | + | pins: pinsToShow(await savedPins(viewer, slug), readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug), | |
| 34 | 37 | }; | |
| 35 | 38 | } | |
| 36 | 39 | ||
| ⋯ | |||
| 41 | 44 | if (!user.workspaces?.some((m) => m.slug === slug)) throw data(null, { status: 404 }); | |
| 42 | 45 | const change = appPinFromForm(await request.formData()); | |
| 43 | 46 | if (!change) return data({ error: "Nothing to do." }, { status: 400 }); | |
| 44 | − | const saved = readCookie(request.headers.get("cookie"), DOCK_COOKIE); | |
| 45 | − | const pins = withPin(pinsIn(saved, slug), change.app, change.pinned); | |
| 47 | + | const cookie = readCookie(request.headers.get("cookie"), DOCK_COOKIE); | |
| 48 | + | // The change is made to the pins as the account keeps them; pins only | |
| 49 | + | // ever kept in this device's cookie are carried over by the first one. | |
| 50 | + | const current = pinsToShow(await savedPins(user, slug), cookie, slug); | |
| 51 | + | const pins = await savePins(user, slug, withPin(current, change.app, change.pinned)); | |
| 52 | + | if (!pins) return data({ error: "Your pins could not be saved. Try again.", pins: current }, { status: 503 }); | |
| 53 | + | // This device's copy, drawn from when the account's cannot be read. | |
| 46 | 54 | const secure = new URL(request.url).protocol === "https:"; | |
| 47 | − | return data({ error: null, pins }, { headers: { "Set-Cookie": dockCookie(writeDock(saved, slug, pins), secure) } }); | |
| 55 | + | return data({ error: null, pins }, { headers: { "Set-Cookie": dockCookie(writeDock(cookie, slug, pins), secure) } }); | |
| 48 | 56 | } | |
| 49 | 57 | ||
| 50 | 58 | export default function Apps({ loaderData }: Route.ComponentProps) { | |
| 1065 | 1065 | pub avatar: Option<String>, | |
| 1066 | 1066 | } | |
| 1067 | 1067 | ||
| 1068 | + | // --- Dock pins ------------------------------------------------------------- | |
| 1069 | + | // | |
| 1070 | + | // The apps a person pins to their dock in a workspace, kept with their | |
| 1071 | + | // account so the dock follows them to every device. Each person's own: | |
| 1072 | + | // nobody else reads or sets them. | |
| 1073 | + | ||
| 1074 | + | /// The most apps a person pins in one workspace. | |
| 1075 | + | pub const MAX_DOCK_PINS: usize = 24; | |
| 1076 | + | /// The most characters an app key takes. | |
| 1077 | + | pub const MAX_DOCK_APP_KEY: usize = 32; | |
| 1078 | + | ||
| 1079 | + | /// `dock_pins`: the apps `user` pinned in the workspace `workspace` (a | |
| 1080 | + | /// slug), in the order they set. Returns `Option<Vec<String>>`: null when | |
| 1081 | + | /// they never saved any there, or are not one of its members. | |
| 1082 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 1083 | + | #[serde(rename_all = "camelCase")] | |
| 1084 | + | pub struct DockPinsArgs { | |
| 1085 | + | pub user: User, | |
| 1086 | + | pub workspace: String, | |
| 1087 | + | } | |
| 1088 | + | ||
| 1089 | + | /// `set_dock_pins`: replaces `user`'s pins in `workspace` with `apps`, in | |
| 1090 | + | /// that order. Each key is lowercase letters, digits and hyphens, at most | |
| 1091 | + | /// [`MAX_DOCK_APP_KEY`] characters; a repeat is dropped; at most | |
| 1092 | + | /// [`MAX_DOCK_PINS`]. Which keys name real apps is the web app's to say. | |
| 1093 | + | /// Returns `Outcome<Vec<String>>`: the pins as saved. | |
| 1094 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 1095 | + | #[serde(rename_all = "camelCase")] | |
| 1096 | + | pub struct SetDockPinsArgs { | |
| 1097 | + | pub user: User, | |
| 1098 | + | pub workspace: String, | |
| 1099 | + | #[serde(default)] | |
| 1100 | + | pub apps: Vec<String>, | |
| 1101 | + | } | |
| 1102 | + | ||
| 1068 | 1103 | /// `directory`: every account or every workspace, as their public pages | |
| 1069 | 1104 | /// show them, a page at a time in name order. For services that index | |
| 1070 | 1105 | /// them, such as search; nothing private is in it. Returns |
| 178 | 178 | updateProfile: (actor, fields) => call("update_profile", { actor, ...fields }), | |
| 179 | 179 | profileWorkspaces: (username, viewer, publicIn) => | |
| 180 | 180 | call("profile_workspaces", { username, viewer, public: publicIn }), | |
| 181 | + | dockPins: (user, workspace) => call("dock_pins", { user, workspace }), | |
| 182 | + | setDockPins: (user, workspace, apps) => call("set_dock_pins", { user, workspace, apps }), | |
| 181 | 183 | listSshKeys: (user) => call("list_ssh_keys", { user }), | |
| 182 | 184 | addSshKey: (user, title, publicKey) => | |
| 183 | 185 | call("add_ssh_key", { user, title, publicKey }), |
| 1034 | 1034 | */ | |
| 1035 | 1035 | profileWorkspaces(username: string, viewer: Viewer, publicIn: string[]): Promise<ProfileWorkspace[]>; | |
| 1036 | 1036 | ||
| 1037 | + | /** | |
| 1038 | + | * The apps `user` pinned to their dock in the workspace `workspace` (a | |
| 1039 | + | * slug), in the order they set; null when they never saved any there or | |
| 1040 | + | * are not one of its members. Kept with the account, so every device | |
| 1041 | + | * shows the same dock. | |
| 1042 | + | */ | |
| 1043 | + | dockPins(user: User, workspace: string): Promise<string[] | null>; | |
| 1044 | + | /** | |
| 1045 | + | * Replaces `user`'s dock pins in `workspace` with `apps`, in that order: | |
| 1046 | + | * keys of lowercase letters, digits and hyphens, repeats dropped, at | |
| 1047 | + | * most `MAX_DOCK_PINS`. Which keys are real apps is the caller's to check. | |
| 1048 | + | */ | |
| 1049 | + | setDockPins(user: User, workspace: string, apps: string[]): Promise<Result<string[]>>; | |
| 1050 | + | ||
| 1037 | 1051 | listSshKeys(user: User): Promise<SshKey[]>; | |
| 1038 | 1052 | /** Takes one line in OpenSSH public key format. */ | |
| 1039 | 1053 | addSshKey(user: User, title: string, publicKey: string): Promise<Result<SshKey>>; | |
| ⋯ | |||
| 1078 | 1092 | /** What an agent's token may do: these operations, in this repository. */ | |
| 1079 | 1093 | export type AgentScope = { repo: RepoPath; operations: string[]; run?: RunBinding }; | |
| 1080 | 1094 | ||
| 1095 | + | /** The most apps a person pins in one workspace. Mirrors `MAX_DOCK_PINS` in `crates/contracts/src/identity.rs`. */ | |
| 1096 | + | export const MAX_DOCK_PINS = 24; | |
| 1097 | + | ||
| 1081 | 1098 | /** The most characters each profile field takes. Mirrors `crates/contracts/src/identity.rs`. */ | |
| 1082 | 1099 | export const PROFILE_LIMITS = { name: 80, bio: 160, location: 80, website: 200, pronouns: 40, timezone: 64 } as const; | |
| 1083 | 1100 | ||
| 1 | + | -- The apps each person pins to their dock, per workspace, so the dock is | |
| 2 | + | -- the same on every device they sign in on. `apps` is a JSON array of app | |
| 3 | + | -- keys (such as ["projects","usage"]) in the order the person set; the | |
| 4 | + | -- web app checks the keys against its list of apps. A workspace with no | |
| 5 | + | -- row has never had its pins saved. See src/dock.rs. | |
| 6 | + | CREATE TABLE dock_pins ( | |
| 7 | + | user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, | |
| 8 | + | workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, | |
| 9 | + | apps TEXT NOT NULL DEFAULT '[]', | |
| 10 | + | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 11 | + | PRIMARY KEY (user_id, workspace_id) | |
| 12 | + | ); |
| 331 | 331 | "ssh_keys", | |
| 332 | 332 | "workspace_members", | |
| 333 | 333 | "team_members", | |
| 334 | + | "dock_pins", | |
| 334 | 335 | ] { | |
| 335 | 336 | sql.push((format!("DELETE FROM {table} WHERE user_id = ?1 AND {STILL}"), 1)); | |
| 336 | 337 | } | |
| ⋯ | |||
| 1178 | 1179 | let sql: Vec<String> = purge_statements().into_iter().map(|(sql, _)| sql).collect(); | |
| 1179 | 1180 | assert!(sql[0].starts_with("INSERT OR REPLACE INTO deleted_users")); | |
| 1180 | 1181 | assert!(sql.iter().any(|s| s.starts_with("UPDATE workspaces SET created_by = 'usr_ghost' WHERE created_by = ?1"))); | |
| 1181 | − | for table in ["user_emails", "github_accounts", "two_factor", "two_factor_recovery", "security_events", "ssh_keys"] { | |
| 1182 | + | for table in ["user_emails", "github_accounts", "two_factor", "two_factor_recovery", "security_events", "ssh_keys", "dock_pins"] { | |
| 1182 | 1183 | assert!(sql.iter().any(|s| s.starts_with(&format!("DELETE FROM {table} WHERE user_id = ?1"))), "{table}"); | |
| 1183 | 1184 | } | |
| 1184 | 1185 | // The row goes last, and everything before it only while the | |
| 718 | 718 | self.db | |
| 719 | 719 | .prepare("DELETE FROM workspace_members WHERE workspace_id = ?") | |
| 720 | 720 | .bind(&[id.into()])?, | |
| 721 | + | // Each member's dock pins in it (dock.rs). | |
| 722 | + | self.db | |
| 723 | + | .prepare("DELETE FROM dock_pins WHERE workspace_id = ?") | |
| 724 | + | .bind(&[id.into()])?, | |
| 721 | 725 | // Its teams, their people and the roles they gave (teams.rs). | |
| 722 | 726 | self.db | |
| 723 | 727 | .prepare("DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?)") |
| 1 | + | //! Dock pins: the apps each person pins to their dock, per workspace, kept | |
| 2 | + | //! with their account so the dock is the same on every device. | |
| 3 | + | //! | |
| 4 | + | //! A row per person and workspace (migration 0044), keyed by the | |
| 5 | + | //! workspace's id so a rename keeps it. Only the person reads or sets | |
| 6 | + | //! their own, and only in a workspace they belong to. The keys are checked | |
| 7 | + | //! for shape here; which apps exist is the web app's list (apps/web's | |
| 8 | + | //! app/lib/apps.ts), so a new app needs no change to this service. | |
| 9 | + | ||
| 10 | + | use g1t_contracts::identity::*; | |
| 11 | + | use g1t_contracts::time::SQL_NOW; | |
| 12 | + | use g1t_contracts::{FailureCode, Outcome}; | |
| 13 | + | use serde::Deserialize; | |
| 14 | + | use worker::Result; | |
| 15 | + | ||
| 16 | + | use crate::Identity; | |
| 17 | + | use crate::security::is_person; | |
| 18 | + | ||
| 19 | + | /// The pins as they are kept: each key checked, repeats dropped, in the | |
| 20 | + | /// order given. Refused whole when a key is malformed or there are too many. | |
| 21 | + | pub fn check_pins(apps: &[String]) -> std::result::Result<Vec<String>, String> { | |
| 22 | + | let mut kept: Vec<String> = Vec::with_capacity(apps.len()); | |
| 23 | + | for app in apps { | |
| 24 | + | let key = app.trim(); | |
| 25 | + | let well_formed = !key.is_empty() | |
| 26 | + | && key.len() <= MAX_DOCK_APP_KEY | |
| 27 | + | && key.starts_with(|c: char| c.is_ascii_lowercase()) | |
| 28 | + | && key.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); | |
| 29 | + | if !well_formed { | |
| 30 | + | return Err("That is not an app you can pin.".to_owned()); | |
| 31 | + | } | |
| 32 | + | if !kept.iter().any(|seen| seen == key) { | |
| 33 | + | kept.push(key.to_owned()); | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | if kept.len() > MAX_DOCK_PINS { | |
| 37 | + | return Err(format!("Pin at most {MAX_DOCK_PINS} apps.")); | |
| 38 | + | } | |
| 39 | + | Ok(kept) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// The pins in a row, or none when the row cannot be read: a bad row is | |
| 43 | + | /// treated as never saved rather than failing the page. | |
| 44 | + | fn parse_pins(apps: &str) -> Option<Vec<String>> { | |
| 45 | + | let keys: Vec<String> = serde_json::from_str(apps).ok()?; | |
| 46 | + | check_pins(&keys).ok() | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | #[derive(Deserialize)] | |
| 50 | + | struct Row { | |
| 51 | + | apps: String, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | impl Identity { | |
| 55 | + | pub async fn dock_pins(&self, a: DockPinsArgs) -> Result<Option<Vec<String>>> { | |
| 56 | + | let slug = a.workspace.trim().to_lowercase(); | |
| 57 | + | if !is_person(&a.user) || !a.user.is_member(&slug) { | |
| 58 | + | return Ok(None); | |
| 59 | + | } | |
| 60 | + | // One query: the workspace by slug, and only while they belong to it. | |
| 61 | + | let row = self | |
| 62 | + | .db | |
| 63 | + | .prepare( | |
| 64 | + | "SELECT d.apps FROM dock_pins d | |
| 65 | + | JOIN workspaces w ON w.id = d.workspace_id AND w.deleted_at IS NULL | |
| 66 | + | JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = d.user_id | |
| 67 | + | WHERE d.user_id = ? AND w.slug = ?", | |
| 68 | + | ) | |
| 69 | + | .bind(&[a.user.id.as_str().into(), slug.into()])? | |
| 70 | + | .first::<Row>(None) | |
| 71 | + | .await?; | |
| 72 | + | Ok(row.and_then(|row| parse_pins(&row.apps))) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | pub async fn set_dock_pins(&self, a: SetDockPinsArgs) -> Result<Outcome<Vec<String>>> { | |
| 76 | + | let slug = a.workspace.trim().to_lowercase(); | |
| 77 | + | if !is_person(&a.user) { | |
| 78 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person has a dock.")); | |
| 79 | + | } | |
| 80 | + | if !a.user.is_member(&slug) { | |
| 81 | + | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 82 | + | } | |
| 83 | + | let apps = match check_pins(&a.apps) { | |
| 84 | + | Ok(apps) => apps, | |
| 85 | + | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 86 | + | }; | |
| 87 | + | let json = serde_json::to_string(&apps)?; | |
| 88 | + | // Written only where they are still a member, in one statement. | |
| 89 | + | let row = self | |
| 90 | + | .db | |
| 91 | + | .prepare(format!( | |
| 92 | + | "INSERT INTO dock_pins (user_id, workspace_id, apps, updated_at) | |
| 93 | + | SELECT ?1, w.id, ?2, {SQL_NOW} FROM workspaces w | |
| 94 | + | JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = ?1 | |
| 95 | + | WHERE w.slug = ?3 AND w.deleted_at IS NULL | |
| 96 | + | ON CONFLICT (user_id, workspace_id) DO UPDATE SET apps = excluded.apps, updated_at = excluded.updated_at | |
| 97 | + | RETURNING apps" | |
| 98 | + | )) | |
| 99 | + | .bind(&[a.user.id.as_str().into(), json.as_str().into(), slug.into()])? | |
| 100 | + | .first::<Row>(None) | |
| 101 | + | .await?; | |
| 102 | + | Ok(match row { | |
| 103 | + | Some(_) => Outcome::Ok(apps), | |
| 104 | + | None => Outcome::fail(FailureCode::NotFound, "Workspace not found."), | |
| 105 | + | }) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | #[cfg(test)] | |
| 110 | + | mod tests { | |
| 111 | + | use super::*; | |
| 112 | + | ||
| 113 | + | fn keys(list: &[&str]) -> Vec<String> { | |
| 114 | + | list.iter().map(|key| (*key).to_owned()).collect() | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | #[test] | |
| 118 | + | fn pins_keep_their_order_without_repeats() { | |
| 119 | + | assert_eq!(check_pins(&keys(&["usage", "projects", "usage", " teams "])).unwrap(), keys(&["usage", "projects", "teams"])); | |
| 120 | + | assert_eq!(check_pins(&[]).unwrap(), Vec::<String>::new()); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[test] | |
| 124 | + | fn a_malformed_key_is_refused() { | |
| 125 | + | for bad in ["", "Projects", "1st", "a b", "../x", "pro_jects", "<script>", &"a".repeat(MAX_DOCK_APP_KEY + 1)] { | |
| 126 | + | assert!(check_pins(&keys(&["projects", bad])).is_err(), "{bad}"); | |
| 127 | + | } | |
| 128 | + | assert!(check_pins(&keys(&["ai-gateway", "v2"])).is_ok()); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | #[test] | |
| 132 | + | fn at_most_the_limit() { | |
| 133 | + | let many: Vec<String> = (0..MAX_DOCK_PINS).map(|n| format!("app-{n}")).collect(); | |
| 134 | + | assert_eq!(check_pins(&many).unwrap().len(), MAX_DOCK_PINS); | |
| 135 | + | let too_many: Vec<String> = (0..=MAX_DOCK_PINS).map(|n| format!("app-{n}")).collect(); | |
| 136 | + | assert!(check_pins(&too_many).is_err()); | |
| 137 | + | // Repeats do not count against it. | |
| 138 | + | let repeated: Vec<String> = many.iter().chain(many.iter()).cloned().collect(); | |
| 139 | + | assert_eq!(check_pins(&repeated).unwrap(), many); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[test] | |
| 143 | + | fn a_bad_row_reads_as_never_saved() { | |
| 144 | + | assert_eq!(parse_pins(r#"["projects","usage"]"#), Some(keys(&["projects", "usage"]))); | |
| 145 | + | assert_eq!(parse_pins("[]"), Some(Vec::new())); | |
| 146 | + | assert_eq!(parse_pins("not json"), None); | |
| 147 | + | assert_eq!(parse_pins(r#"["BAD"]"#), None); | |
| 148 | + | } | |
| 149 | + | } |
| 13 | 13 | mod deploy_keys; | |
| 14 | 14 | mod device; | |
| 15 | 15 | mod directory; | |
| 16 | + | mod dock; | |
| 16 | 17 | mod email; | |
| 17 | 18 | mod emails; | |
| 18 | 19 | mod github; | |
| ⋯ | |||
| 990 | 991 | } | |
| 991 | 992 | "directory" => reply(&identity.directory(args(body)?).await?), | |
| 992 | 993 | "profile_workspaces" => reply(&identity.profile_workspaces(args(body)?).await?), | |
| 994 | + | // Each person's dock pins, per workspace; see dock.rs. | |
| 995 | + | "dock_pins" => reply(&identity.dock_pins(args(body)?).await?), | |
| 996 | + | "set_dock_pins" => reply(&identity.set_dock_pins(args(body)?).await?), | |
| 993 | 997 | "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?), | |
| 994 | 998 | // Services only: who registered each key, for verifying commit | |
| 995 | 999 | // signatures (repos' signatures.rs). | |