diff --git a/apps/web/e2e/browser-lock.spec.ts b/apps/web/e2e/browser-lock.spec.ts index 3106fe9d..80621173 100644 --- a/apps/web/e2e/browser-lock.spec.ts +++ b/apps/web/e2e/browser-lock.spec.ts @@ -327,7 +327,7 @@ test("lock broadcast in one tab locks another open tab", async ({ await context.close(); }); -test("shell loading state is text-first, not the large six-dot grip", async ({ +test("shell loading state is a quiet skeleton, not the large six-dot grip", async ({ page, }) => { await mockApi(page, { userDelayMs: 1_000 }); @@ -336,7 +336,7 @@ test("shell loading state is text-first, not the large six-dot grip", async ({ await page.goto(`${APP}/app/project`, { waitUntil: "domcontentloaded" }); await expect( - page.getByRole("status").filter({ hasText: "Loading" }), + page.getByRole("status", { name: "Loading projects" }), ).toBeVisible(); await expect(page.locator('svg[aria-label="Loading"]')).toHaveCount(0); }); @@ -417,26 +417,22 @@ test("unsaved edits survive tab switches and project switches go through the gua await page.getByRole("textbox", { name: "Passphrase" }).fill(passphrase); await page.getByRole("button", { name: "Unlock" }).click(); + // Raw .env view: the editor lives in Monaco there (the table is the default view). + await page.getByRole("radio", { name: ".env" }).click(); const editor = page.locator(".monaco-editor .view-lines"); await editor.click(); await page.keyboard.press("ControlOrMeta+End"); await page.keyboard.press("Enter"); await page.keyboard.type('UNSAVED="1"'); - await expect( - page.getByRole("button", { name: "Save", exact: true }), - ).toBeEnabled(); - - await page - .getByRole("button", { name: "History", exact: true }) - .first() - .click(); - await page - .getByRole("button", { name: "Editor", exact: true }) - .first() - .click(); + await expect(page.getByRole("button", { name: /^Save/ })).toBeEnabled(); + + const tabs = page.getByRole("navigation", { name: "Project" }); + await tabs.getByRole("link", { name: "History" }).click(); + await expect(page).toHaveURL(/project-eval\/history$/); + await tabs.getByRole("link", { name: "Secrets" }).click(); await expect(editor).toContainText("UNSAVED"); - await page.locator("aside nav a", { hasText: "Second Project" }).click(); + await page.getByRole("link", { name: /Second Project/ }).click(); const guard = page.getByRole("dialog", { name: "Save your changes?" }); await expect(guard).toBeVisible(); await expect(page).toHaveURL(/project-eval$/); diff --git a/apps/web/e2e/visual-parity.spec.ts b/apps/web/e2e/visual-parity.spec.ts index 07b1b1e0..95c30380 100644 --- a/apps/web/e2e/visual-parity.spec.ts +++ b/apps/web/e2e/visual-parity.spec.ts @@ -14,9 +14,6 @@ import { PNG } from "pngjs"; const REACT = process.env.REACT_URL ?? "http://127.0.0.1:5173"; const SVELTE = process.env.SVELTE_URL ?? "http://127.0.0.1:9090"; -const PROJECT_ID = process.env.PARITY_PROJECT_ID ?? "69e4efc7ed94cab2c9b88c9b"; -const REACT_STORAGE_STATE = process.env.PARITY_REACT_STORAGE_STATE; -const SVELTE_STORAGE_STATE = process.env.PARITY_SVELTE_STORAGE_STATE; test.beforeAll(async ({ request }, testInfo) => { const check = async (url: string) => { @@ -97,56 +94,3 @@ for (const route of Object.keys(THRESHOLDS)) { ).toBeLessThanOrEqual(threshold); }); } - -test("authenticated history tab visual parity", async ({ - browser, -}, testInfo) => { - if (!REACT_STORAGE_STATE || !SVELTE_STORAGE_STATE) { - testInfo.skip( - true, - "History parity needs authenticated storage states. Provide PARITY_REACT_STORAGE_STATE and PARITY_SVELTE_STORAGE_STATE.", - ); - } - - const route = `/app/project/${PROJECT_ID}`; - const reactContext = await browser.newContext({ - storageState: REACT_STORAGE_STATE, - viewport: { width: 1440, height: 900 }, - deviceScaleFactor: 1, - colorScheme: "dark", - }); - const svelteContext = await browser.newContext({ - storageState: SVELTE_STORAGE_STATE, - viewport: { width: 1440, height: 900 }, - deviceScaleFactor: 1, - colorScheme: "dark", - }); - - const reactPage = await reactContext.newPage(); - await reactPage.goto(`${REACT}${route}`, { waitUntil: "load" }); - await reactPage.getByRole("button", { name: "History" }).click(); - await expect( - reactPage.getByPlaceholder("Search edits — author, email, diff content…"), - ).toBeVisible(); - await reactPage.waitForTimeout(1200); - - const sveltePage = await svelteContext.newPage(); - await sveltePage.goto(`${SVELTE}${route}`, { waitUntil: "load" }); - await sveltePage.getByRole("button", { name: "History" }).click(); - await expect( - sveltePage.getByPlaceholder("Search edits — author, email, diff content…"), - ).toBeVisible(); - await sveltePage.waitForTimeout(1200); - - const reactShot = await reactPage.locator("main").screenshot(); - const svelteShot = await sveltePage.locator("main").screenshot(); - - await reactContext.close(); - await svelteContext.close(); - - const ratio = mismatchRatio(reactShot, svelteShot); - expect( - ratio, - `History tab pixel mismatch ${(ratio * 100).toFixed(2)}% for ${route}.`, - ).toBeLessThanOrEqual(0.03); -}); diff --git a/apps/web/package.json b/apps/web/package.json index 3a017997..935cad9b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,7 +20,8 @@ }, "dependencies": { "@fontsource-variable/funnel-display": "^5.2.8", - "@monaco-editor/loader": "^1.5.0", + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", "@packages/backend-sdk": "workspace:*", "@tabler/icons-svelte": "^3.34.1", "clsx": "^2.1.1", diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 13433a40..ff5b698a 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1,9 +1,13 @@ @import "@fontsource-variable/funnel-display"; +@import "@fontsource-variable/geist"; +@import "@fontsource-variable/geist-mono"; @import "tailwindcss"; @plugin "daisyui" { logs: false; } @import "tw-animate-css"; +@import "./lib/appearance/greys.css"; +@import "./lib/appearance/theme.css"; @custom-variant dark (&:is(.dark *)); @@ -50,6 +54,54 @@ --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); --color-editor: #1e1e1e; + /* App design system (lib/appearance/theme.css). */ + --font-ui: "Geist Variable", system-ui, sans-serif; + --font-code: "Geist Mono Variable", ui-monospace, monospace; + --color-root: var(--root); + --color-root-hover: var(--root-hover); + --color-root-sel: var(--root-sel); + --color-s50: var(--s50); + --color-s50-hover: var(--s50-hover); + --color-s50-sel: var(--s50-sel); + --color-s50-border: var(--s50-border); + --color-s100: var(--s100); + --color-s100-hover: var(--s100-hover); + --color-s100-sel: var(--s100-sel); + --color-s100-border: var(--s100-border); + --color-s150: var(--s150); + --color-s150-hover: var(--s150-hover); + --color-s150-border: var(--s150-border); + --color-elev: var(--elev); + --color-elev-hover: var(--elev-hover); + --color-elev-border: var(--elev-border); + --color-field: var(--field); + --color-field-border: var(--field-border); + --color-fg: var(--fg); + --color-fg2: var(--fg2); + --color-fg3: var(--fg3); + --color-ph: var(--ph); + --color-faint: var(--faint); + --color-btn: var(--btn); + --color-btn-hover: var(--btn-hover); + --color-btn-border: var(--btn-border); + --color-btn-primary: var(--btn-primary); + --color-btn-primary-hover: var(--btn-primary-hover); + --color-btn-primary-fg: var(--btn-primary-fg); + --color-success: var(--success); + --color-warning: var(--warning); + --color-danger: var(--danger); + --color-danger-bg: var(--danger-bg); + --color-danger-bg-hover: var(--danger-bg-hover); + --radius-panel: 24px; + --radius-well: 18px; + --radius-card: 12px; + --radius-row: 6px; + --shadow-raise: var(--raise-shadow); + --shadow-panel: inset 0 0 0 0.5px var(--s50-border); + --shadow-card: inset 0 0 0 0.5px var(--s100-border), var(--raise-shadow); + --shadow-btn: inset 0 0 0 0.5px var(--btn-border), var(--raise-shadow); + --shadow-field: inset 0 0 0 0.5px var(--field-border); + --shadow-elev: 0 0 0 0.5px var(--elev-border), var(--elev-shadow); --animate-marquee: marquee var(--duration) infinite linear; --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; @keyframes marquee { @@ -202,6 +254,15 @@ body { min-height: 100%; } +/* Monospace for keys and values. Ligatures off so "==" or "->" never render as one glyph. */ +@utility mono { + font-family: var(--font-code); + font-variant-ligatures: none; + font-feature-settings: + "liga" 0, + "calt" 0; +} + body { margin: 0; } diff --git a/apps/web/src/app.html b/apps/web/src/app.html index 44290495..b97a44a7 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -9,6 +9,40 @@ /> + diff --git a/apps/web/src/lib/account/ui/AccountSettingsPage.svelte b/apps/web/src/lib/account/ui/AccountSettingsPage.svelte new file mode 100644 index 00000000..e3d0fa79 --- /dev/null +++ b/apps/web/src/lib/account/ui/AccountSettingsPage.svelte @@ -0,0 +1,340 @@ + + + + +{#snippet copyButton(text: string, label: string)} + void copy(text)}> + {#if copied === text} + + {:else} + + {/if} + +{/snippet} + +{#snippet swatch(name: GreyPreset)} + {@const on = appearance.greys === name} + {@const [dark, mid, light] = GREY_SWATCHES[name]} + +{/snippet} + +
+ {#if user?.displayName} + {user.displayName} + / + {/if} + Settings +
+ + + + + {#if renaming} +
+ { + if (event.key === "Escape") { + event.preventDefault(); + renaming = false; + } + }} + /> +
+ {:else} + {user?.displayName} + {/if} + {#snippet actions()} + {#if renaming} + + + {:else} + + {/if} + {/snippet} +
+ + {#if user} + {PROVIDERS[user.authMethod]} + {#if user.email} + {user.email} + {/if} + {/if} + {#snippet actions()} + + {/snippet} + +
+ + + + {themeNote} + {#snippet actions()} + appearance.theme, setTheme} + /> + {/snippet} + + +
+ {#each GREY_PRESETS as name (name)} + {@render swatch(name)} + {/each} +
+
+ + + +
+ + + + Cryptly never sees it and cannot reset it. + {#snippet actions()} + + {/snippet} + + + + + + {device} + {keyAuth.hasMasterKey ? "Unlocked" : "Locked"} + {#snippet actions()} + {#if keyAuth.hasMasterKey} + + {/if} + {/snippet} + + + {INSTALL} + {#snippet actions()} + {@render copyButton(INSTALL, "Copy install command")} + {/snippet} + + + {LOGIN} + {#snippet actions()} + {@render copyButton(LOGIN, "Copy login command")} + {/snippet} + + +
+ + diff --git a/apps/web/src/lib/account/ui/AppPreview.svelte b/apps/web/src/lib/account/ui/AppPreview.svelte new file mode 100644 index 00000000..e724e888 --- /dev/null +++ b/apps/web/src/lib/account/ui/AppPreview.svelte @@ -0,0 +1,249 @@ + + + + + diff --git a/apps/web/src/lib/account/ui/ChangePassphraseDialog.svelte b/apps/web/src/lib/account/ui/ChangePassphraseDialog.svelte new file mode 100644 index 00000000..bfdd3e6e --- /dev/null +++ b/apps/web/src/lib/account/ui/ChangePassphraseDialog.svelte @@ -0,0 +1,154 @@ + + + +
+

+ Other browsers and cryptly login will ask + for the new passphrase next time. This browser stays unlocked. +

+ + + (error = null)} + /> + {#if error === "incorrect"} +

Incorrect passphrase.

+ {/if} + (error = null)} + /> + (error = null)} + /> + {#if error === "mismatch"} +

Passphrases don't match.

+ {:else if error === "same"} +

+ The new passphrase is the same as the current one. +

+ {:else if error === "failed"} +

+ Couldn't change the passphrase. Try again. +

+ {/if} +

+ + If you forget it, the account has to be reset. +

+
+ + +
+ +
diff --git a/apps/web/src/lib/account/ui/PassphraseField.svelte b/apps/web/src/lib/account/ui/PassphraseField.svelte new file mode 100644 index 00000000..fc85683a --- /dev/null +++ b/apps/web/src/lib/account/ui/PassphraseField.svelte @@ -0,0 +1,58 @@ + + +
+ +
+ + (shown = !shown)} + > + {#if shown} + + {:else} + + {/if} + +
+
diff --git a/apps/web/src/lib/api/integrations.api.ts b/apps/web/src/lib/api/integrations.api.ts index 611b163c..430869db 100644 --- a/apps/web/src/lib/api/integrations.api.ts +++ b/apps/web/src/lib/api/integrations.api.ts @@ -129,11 +129,17 @@ export class IntegrationsApi { } } - static acknowledgeSecretsPushed(jwtToken: string, projectId: string) { + /** Records the push (analytics + `lastPushedAt` on these integrations for the sync status). */ + static acknowledgeSecretsPushed( + jwtToken: string, + projectId: string, + integrationIds: string[], + ) { return unwrap( backend.POST("/projects/{projectId}/analytics/secrets-pushed", { params: { path: { projectId } }, headers: bearer(jwtToken), + body: { integrationIds }, }), "Failed to acknowledge secrets push", ); @@ -144,8 +150,36 @@ export class IntegrationsApi { integrations: Integration[], content: string, ): Promise { - if (publicEnv.githubLocalMock) return; + const targets = integrations.filter( + (integration) => + integration.repositoryData?.owner && integration.repositoryData.name, + ); + // The local GitHub mock never sends secrets anywhere; it still records the push below so + // the sync status works locally. + if (!publicEnv.githubLocalMock) { + await this.pushToRepositories(jwtToken, targets, content); + } + + const projectId = integrations[0]?.projectId; + if (projectId) { + try { + await this.acknowledgeSecretsPushed( + jwtToken, + projectId, + targets.map((integration) => integration.id), + ); + } catch { + // the push itself succeeded; a missing record only leaves the sync status stale + } + } + } + + private static async pushToRepositories( + jwtToken: string, + integrations: Integration[], + content: string, + ): Promise { const secrets = parseDotenv(content); await Promise.all( integrations.map(async (integration) => { @@ -175,14 +209,6 @@ export class IntegrationsApi { ); }), ); - const pid = integrations[0]?.projectId; - if (pid) { - try { - await this.acknowledgeSecretsPushed(jwtToken, pid); - } catch { - // analytics must not fail the push flow - } - } } } diff --git a/apps/web/src/lib/appearance/appearance.svelte.ts b/apps/web/src/lib/appearance/appearance.svelte.ts new file mode 100644 index 00000000..1df5cbd4 --- /dev/null +++ b/apps/web/src/lib/appearance/appearance.svelte.ts @@ -0,0 +1,78 @@ +import { browser } from "$app/environment"; +import { GREY_PRESETS, type GreyPreset } from "./greys"; + +export type ThemeChoice = "system" | "light" | "dark"; + +/** Must match the key read by the pre-paint script in app.html. */ +const STORAGE_KEY = "cryptly.appearance"; +const DEFAULT_GREYS: GreyPreset = "Stone"; + +function read(): { theme: ThemeChoice; greys: GreyPreset } { + let saved: { theme?: unknown; greys?: unknown } = {}; + if (browser) { + try { + saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") ?? {}; + } catch { + saved = {}; + } + } + const theme = + saved.theme === "light" || saved.theme === "dark" ? saved.theme : "system"; + const greys = GREY_PRESETS.includes(saved.greys as GreyPreset) + ? (saved.greys as GreyPreset) + : DEFAULT_GREYS; + return { theme, greys }; +} + +const systemLight = browser + ? matchMedia("(prefers-color-scheme: light)") + : null; + +export const appearance = $state({ + ...read(), + /** The theme in effect after resolving "system". */ + resolved: "dark" as "light" | "dark", +}); + +function apply() { + if (!browser) return; + appearance.resolved = + appearance.theme === "system" + ? systemLight?.matches + ? "light" + : "dark" + : appearance.theme; + document.documentElement.dataset.theme = appearance.resolved; + document.documentElement.dataset.greys = appearance.greys; +} + +function save() { + if (!browser) return; + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ theme: appearance.theme, greys: appearance.greys }), + ); +} + +export function setTheme(theme: ThemeChoice) { + appearance.theme = theme; + save(); + apply(); +} + +export function setGreys(greys: GreyPreset) { + appearance.greys = greys; + save(); + apply(); +} + +if (browser) { + apply(); + systemLight?.addEventListener("change", apply); + // Keep other tabs in sync. + window.addEventListener("storage", (event) => { + if (event.key !== STORAGE_KEY) return; + Object.assign(appearance, read()); + apply(); + }); +} diff --git a/apps/web/src/lib/appearance/greys.css b/apps/web/src/lib/appearance/greys.css new file mode 100644 index 00000000..4b11fbab --- /dev/null +++ b/apps/web/src/lib/appearance/greys.css @@ -0,0 +1,222 @@ +/* Grey ramps for the app UI. One preset is active per html[data-greys]; themes map semantic tokens onto these steps. */ +html[data-greys="Neutral"] { + --g50: #fbfbfb; + --g100: #f5f5f5; + --g150: #f0f0f0; + --g200: #eaeaea; + --g250: #dedede; + --g300: #d8d8d8; + --g350: #cdcdcd; + --g400: #b6b6b6; + --g450: #979797; + --g500: #828282; + --g550: #717171; + --g600: #666666; + --g650: #4f4f4f; + --g700: #404040; + --g750: #373737; + --g800: #2f2f2f; + --g850: #292929; + --g900: #1f1f1f; + --g925: #1a1a1a; + --g950: #141414; +} +:root, +html[data-greys="Stone"] { + --g50: #fcfbfb; + --g100: #f7f5f4; + --g150: #f2efee; + --g200: #ece9e7; + --g250: #e1dddb; + --g300: #dbd7d4; + --g350: #d1ccc9; + --g400: #b9b5b2; + --g450: #9b9693; + --g500: #86817e; + --g550: #75706d; + --g600: #6a6562; + --g650: #534f4c; + --g700: #44403d; + --g750: #393633; + --g800: #322f2d; + --g850: #2b2826; + --g900: #211f1d; + --g925: #1c1a18; + --g950: #161413; +} +html[data-greys="Paper"] { + --g50: #fcfbfa; + --g100: #f6f5f3; + --g150: #f1f0ed; + --g200: #eceae6; + --g250: #e0ded9; + --g300: #dad7d2; + --g350: #d0cdc7; + --g400: #b8b5af; + --g450: #9a9790; + --g500: #85827c; + --g550: #74716b; + --g600: #696660; + --g650: #524f4a; + --g700: #43403b; + --g750: #393632; + --g800: #312f2b; + --g850: #2b2925; + --g900: #211f1c; + --g925: #1b1a17; + --g950: #151412; +} +html[data-greys="Sand"] { + --g50: #fcfbfa; + --g100: #f7f5f2; + --g150: #f3efeb; + --g200: #eee9e4; + --g250: #e3ddd7; + --g300: #ddd7cf; + --g350: #d3ccc4; + --g400: #bcb4ac; + --g450: #9d968d; + --g500: #898178; + --g550: #777067; + --g600: #6c655d; + --g650: #554e47; + --g700: #463f38; + --g750: #3b362f; + --g800: #342f29; + --g850: #2d2823; + --g900: #231e1a; + --g925: #1d1916; + --g950: #171411; +} +html[data-greys="Clay"] { + --g50: #fdfbfa; + --g100: #f8f5f3; + --g150: #f4eeed; + --g200: #efe8e6; + --g250: #e4dcd9; + --g300: #dfd6d2; + --g350: #d5cbc7; + --g400: #beb3b0; + --g450: #9f9491; + --g500: #8a807c; + --g550: #796e6b; + --g600: #6e6460; + --g650: #564d4a; + --g700: #473e3b; + --g750: #3c3532; + --g800: #352e2b; + --g850: #2e2725; + --g900: #231e1c; + --g925: #1e1917; + --g950: #171312; +} +html[data-greys="Rose"] { + --g50: #fcfbfb; + --g100: #f7f4f5; + --g150: #f3eeef; + --g200: #eee8e9; + --g250: #e3dcdd; + --g300: #ddd6d6; + --g350: #d3cbcc; + --g400: #bcb3b4; + --g450: #9d9495; + --g500: #898081; + --g550: #776e6f; + --g600: #6c6465; + --g650: #554d4e; + --g700: #463e3f; + --g750: #3b3535; + --g800: #342e2e; + --g850: #2d2828; + --g900: #231e1e; + --g925: #1d1919; + --g950: #171314; +} +html[data-greys="Plum"] { + --g50: #fcfbfc; + --g100: #f7f5f7; + --g150: #f2eff2; + --g200: #ece9ed; + --g250: #e1dce1; + --g300: #dbd6db; + --g350: #d1cbd1; + --g400: #bab3ba; + --g450: #9b959b; + --g500: #868086; + --g550: #756f75; + --g600: #6a646a; + --g650: #534d53; + --g700: #443f44; + --g750: #3a353a; + --g800: #322e32; + --g850: #2b282c; + --g900: #211e21; + --g925: #1c191c; + --g950: #161416; +} +html[data-greys="Sage"] { + --g50: #fbfcfb; + --g100: #f4f6f4; + --g150: #eef1ee; + --g200: #e8ebe8; + --g250: #dbdfdb; + --g300: #d4d9d4; + --g350: #c9cfc9; + --g400: #b2b8b2; + --g450: #939993; + --g500: #7e847e; + --g550: #6d736d; + --g600: #626862; + --g650: #4c514c; + --g700: #3d423d; + --g750: #343834; + --g800: #2d312d; + --g850: #272a27; + --g900: #1d201d; + --g925: #181b18; + --g950: #131513; +} +html[data-greys="Slate"] { + --g50: #fafbfd; + --g100: #f4f6f8; + --g150: #edf0f3; + --g200: #e7ebef; + --g250: #dadfe4; + --g300: #d3d8de; + --g350: #c8ced4; + --g400: #b0b7bd; + --g450: #91989f; + --g500: #7d838a; + --g550: #6b7278; + --g600: #61676d; + --g650: #4b5056; + --g700: #3c4147; + --g750: #32373c; + --g800: #2c3035; + --g850: #262a2e; + --g900: #1c2023; + --g925: #181a1e; + --g950: #121517; +} +html[data-greys="Ink"] { + --g50: #fafbfe; + --g100: #f3f5fa; + --g150: #ecf0f7; + --g200: #e6eaf3; + --g250: #d9dee9; + --g300: #d2d8e5; + --g350: #c6cddb; + --g400: #afb6c5; + --g450: #9097a6; + --g500: #7b8291; + --g550: #6a717f; + --g600: #5f6674; + --g650: #49505d; + --g700: #3b414d; + --g750: #313742; + --g800: #2b303a; + --g850: #252932; + --g900: #1c1f27; + --g925: #171a21; + --g950: #12141a; +} diff --git a/apps/web/src/lib/appearance/greys.ts b/apps/web/src/lib/appearance/greys.ts new file mode 100644 index 00000000..1d0b3eae --- /dev/null +++ b/apps/web/src/lib/appearance/greys.ts @@ -0,0 +1,29 @@ +/** Grey presets, in display order. Each maps to an html[data-greys] block in greys.css. */ +export const GREY_PRESETS = [ + "Neutral", + "Stone", + "Paper", + "Sand", + "Clay", + "Rose", + "Plum", + "Sage", + "Slate", + "Ink", +] as const; + +export type GreyPreset = (typeof GREY_PRESETS)[number]; + +/** Swatch colors for the Appearance picker: dark, mid and light steps of each ramp. */ +export const GREY_SWATCHES: Record = { + Neutral: ["#141414", "#828282", "#f0f0f0"], + Stone: ["#161413", "#86817e", "#f2efee"], + Paper: ["#151412", "#85827c", "#f1f0ed"], + Sand: ["#171411", "#898178", "#f3efeb"], + Clay: ["#171312", "#8a807c", "#f4eeed"], + Rose: ["#171314", "#898081", "#f3eeef"], + Plum: ["#161416", "#868086", "#f2eff2"], + Sage: ["#131513", "#7e847e", "#eef1ee"], + Slate: ["#121517", "#7d838a", "#edf0f3"], + Ink: ["#12141a", "#7b8291", "#ecf0f7"], +}; diff --git a/apps/web/src/lib/appearance/theme.css b/apps/web/src/lib/appearance/theme.css new file mode 100644 index 00000000..2564c7f3 --- /dev/null +++ b/apps/web/src/lib/appearance/theme.css @@ -0,0 +1,86 @@ +/* + * App design tokens. One grey ramp (greys.css) feeds both themes; each theme maps the same + * semantic tokens onto different steps. The pre-paint script in app.html sets data-theme and + * data-greys before first paint. Surfaces nest root → s50 (panel) → root (well) → s100 (card). + */ +html[data-theme="dark"] { + --scheme: dark; + --root: var(--g950); + --root-hover: var(--g900); + --root-sel: var(--g850); + --s50: var(--g925); + --s50-hover: var(--g900); + --s50-sel: var(--g850); + --s50-border: var(--g850); + --s100: var(--g850); + --s100-hover: var(--g800); + --s100-sel: var(--g750); + --s100-border: var(--g800); + --s150: var(--g800); + --s150-hover: var(--g750); + --s150-border: var(--g700); + --elev: var(--g800); + --elev-hover: var(--g750); + --elev-border: var(--g700); + --field: var(--g950); + --field-border: var(--g800); + --fg: var(--g100); + --fg2: var(--g400); + --fg3: var(--g500); + --ph: var(--g550); + --faint: var(--g650); + --btn: var(--g800); + --btn-hover: var(--g750); + --btn-border: var(--g700); + --btn-primary: var(--g50); + --btn-primary-hover: var(--g200); + --btn-primary-fg: var(--g950); + --success: #4ade80; + --warning: #fbbf24; + --danger: #f87171; + --danger-bg: #3b1d1c; + --danger-bg-hover: #4a2321; + --raise-shadow: 0 1px 2px #0000004d; + --elev-shadow: 0 12px 32px #00000066; +} + +html[data-theme="light"] { + --scheme: light; + --root: var(--g200); + --root-hover: var(--g250); + --root-sel: var(--g300); + --s50: var(--g150); + --s50-hover: var(--g200); + --s50-sel: var(--g250); + --s50-border: var(--g250); + --s100: var(--g50); + --s100-hover: var(--g150); + --s100-sel: var(--g200); + --s100-border: var(--g250); + --s150: var(--g50); + --s150-hover: var(--g100); + --s150-border: var(--g250); + --elev: var(--g50); + --elev-hover: var(--g150); + --elev-border: var(--g250); + --field: var(--g100); + --field-border: var(--g250); + --fg: var(--g900); + --fg2: var(--g700); + --fg3: var(--g600); + --ph: var(--g500); + --faint: var(--g400); + --btn: var(--g50); + --btn-hover: var(--g150); + --btn-border: var(--g250); + --btn-primary: var(--g950); + --btn-primary-hover: var(--g800); + --btn-primary-fg: var(--g50); + --success: #16a34a; + --warning: #c27803; + --danger: #d82d2a; + --danger-bg: #fde8e7; + --danger-bg-hover: #fbd5d3; + --raise-shadow: 0 1px 2px #0000000d; + --elev-shadow: 0 12px 32px #0000001f; +} diff --git a/apps/web/src/lib/auth/change-passphrase.ts b/apps/web/src/lib/auth/change-passphrase.ts new file mode 100644 index 00000000..c6e167c0 --- /dev/null +++ b/apps/web/src/lib/auth/change-passphrase.ts @@ -0,0 +1,24 @@ +import { broadcastPassphraseChanged } from "$lib/stores/key-state.svelte"; +import { decryptPrivateKey, encryptPrivateKey } from "./private-key"; +import { UserApi } from "./user.api"; + +export { IncorrectPassphraseError } from "./private-key"; + +/** + * Changes the account passphrase by re-encrypting the private key in the browser. The key pair + * does not change, so project keys, this unlocked browser and logged-in CLIs keep working. + * Only the re-encrypted key is sent; the passphrases and the decrypted key never leave this + * function. Nothing is written when `current` is wrong (IncorrectPassphraseError). + */ +export async function changePassphrase( + jwtToken: string, + current: string, + next: string, +): Promise { + const privateKeyEncrypted = await encryptPrivateKey( + await decryptPrivateKey(current), + next, + ); + await UserApi.updateMe(jwtToken, { privateKeyEncrypted }); + broadcastPassphraseChanged(); +} diff --git a/apps/web/src/lib/auth/private-key.ts b/apps/web/src/lib/auth/private-key.ts new file mode 100644 index 00000000..65f98ec5 --- /dev/null +++ b/apps/web/src/lib/auth/private-key.ts @@ -0,0 +1,63 @@ +import { auth, setUserData } from "$lib/stores/auth.svelte"; +import { SymmetricCrypto } from "./symmetric-crypto"; +import { UserApi } from "./user.api"; + +/** + * The account private key (PKCS#8, base64) is stored on the server encrypted with AES-GCM keyed + * by SHA-256 of the passphrase. Every path that opens or writes it goes through this module, so + * setup, unlock, device approval, CLI login and passphrase change share one format. + */ + +export class IncorrectPassphraseError extends Error { + constructor() { + super("Incorrect passphrase"); + this.name = "IncorrectPassphraseError"; + } +} + +/** + * The server's current encrypted private key. A copy loaded earlier may predate a passphrase + * change (in another tab or browser), and would still open with the old passphrase. Falls back + * to the loaded copy when the API is unreachable. + */ +async function currentPrivateKeyEncrypted(): Promise { + const jwt = auth.jwtToken; + if (jwt) { + try { + const me = await UserApi.getMe(jwt); + if (auth.jwtToken === jwt) setUserData(me); + return me.privateKeyEncrypted; + } catch { + // Offline or API error: use the copy we have. + } + } + return auth.userData?.privateKeyEncrypted; +} + +/** Opens the account private key with `passphrase`. Throws IncorrectPassphraseError. */ +export async function decryptPrivateKey(passphrase: string): Promise { + const encrypted = await currentPrivateKeyEncrypted(); + if (!encrypted) throw new Error("This account has no passphrase yet"); + const key = await SymmetricCrypto.deriveBase64KeyFromPassphrase(passphrase); + try { + return await SymmetricCrypto.decrypt(encrypted, key); + } catch { + throw new IncorrectPassphraseError(); + } +} + +/** + * Encrypts the private key with `passphrase`, then opens the result again before it can leave + * the browser: a blob the passphrase can't open would lock the account out for good. + */ +export async function encryptPrivateKey( + privateKey: string, + passphrase: string, +): Promise { + const key = await SymmetricCrypto.deriveBase64KeyFromPassphrase(passphrase); + const encrypted = await SymmetricCrypto.encrypt(privateKey, key); + if ((await SymmetricCrypto.decrypt(encrypted, key)) !== privateKey) { + throw new Error("Could not verify the encrypted private key"); + } + return encrypted; +} diff --git a/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte b/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte index e26b1645..692c1fc5 100644 --- a/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte +++ b/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte @@ -18,6 +18,7 @@ type CliSessionInfo, } from "$lib/auth/cli-flow.api"; import { randomBytes, u8ToBase64 } from "$lib/auth/crypto.utils"; + import { decryptPrivateKey } from "$lib/auth/private-key"; import { SymmetricCrypto } from "$lib/auth/symmetric-crypto"; import { auth, loadUserData } from "$lib/stores/auth.svelte"; @@ -134,16 +135,8 @@ let ephemeralKey: string | null = null; try { - const base64Key = - await SymmetricCrypto.deriveBase64KeyFromPassphrase(passphrase); - try { - rawPrivateKey = await SymmetricCrypto.decrypt( - auth.userData.privateKeyEncrypted, - base64Key, - ); - } catch { - throw new Error("Incorrect passphrase"); - } + // Throws "Incorrect passphrase"; checked against the server's current key. + rawPrivateKey = await decryptPrivateKey(passphrase); ephemeralKey = u8ToBase64(randomBytes(32)); const wrappedKey = await AsymmetricCrypto.encrypt( diff --git a/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte b/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte index e7da834b..ed14923a 100644 --- a/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte +++ b/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte @@ -1,13 +1,15 @@ -{#if msg?.type === "request"} -
+ + {#if typeof msg?.pin === "string"} + {/if} -
- -
- { - if (e.key === "Enter" && passphrase && !processing) - void handleApprove(); - }} - /> - -
- {#if passphraseError} -
- Incorrect passphrase -
+
+
+ + { + if (passphraseError) passphraseError = false; + }} + onkeydown={(e) => { + if (e.key === "Enter" && passphrase && !processing) + void handleApprove(); + }} + /> + { + showPassphrase = !showPassphrase; + }} + > + {#if showPassphrase} + + {:else} + {/if} -
- -
- - -
+
+ {#if passphraseError} +

Incorrect passphrase

+ {/if} +
+ +
+ +
-{/if} +
diff --git a/apps/web/src/lib/history/diff.ts b/apps/web/src/lib/history/diff.ts new file mode 100644 index 00000000..19d265a8 --- /dev/null +++ b/apps/web/src/lib/history/diff.ts @@ -0,0 +1,184 @@ +import { diffArrays } from "diff"; +import type { ProjectRevealOn } from "$lib/auth/domain/project-settings"; +import { oneLine, parseEnv as parseEnvDoc, peek } from "$lib/secrets/env-doc"; + +/** One secret of a decrypted .env document, with the group it sits in on the Secrets page. */ +export interface EnvSecret { + key: string; + value: string; + group: string; + /** Index of the group block, so two "Untitled" groups stay apart. */ + block: number; +} + +export interface SecretChange { + kind: "added" | "changed" | "removed"; + key: string; + before?: string; + after?: string; +} + +export interface SecretDiff { + /** Changed secrets grouped like the Secrets page, in the newer document's group order. */ + groups: { name: string; changes: SecretChange[] }[]; + added: number; + changed: number; + removed: number; + unchanged: number; + unchangedGroups: number; +} + +/** Secrets with their groups, parsed exactly like the Secrets page (`env-doc.ts`). */ +export function parseEnv(text: string): EnvSecret[] { + return parseEnvDoc(text).groups.flatMap((g) => + g.secrets.map((s) => ({ + key: s.key, + value: s.value, + group: g.name ?? "Untitled", + block: g.index, + })), + ); +} + +/** + * Per-secret diff by key. Moving a secret to another group or reordering it is not a change; + * a key defined twice counts with its last value, like dotenv. + */ +export function diffSecrets( + before: EnvSecret[], + after: EnvSecret[], +): SecretDiff { + const old = new Map(before.map((s) => [s.key, s])); + const now = new Map(after.map((s) => [s.key, s])); + const order = new Map(); + for (const s of [...now.values(), ...old.values()]) + if (!order.has(s.group)) order.set(s.group, order.size); + + const byGroup = new Map(); + const push = (group: string, change: SecretChange) => + byGroup.set(group, [...(byGroup.get(group) ?? []), change]); + let added = 0; + let changed = 0; + let removed = 0; + let unchanged = 0; + const unchangedBlocks = new Set(); + + for (const s of now.values()) { + const prev = old.get(s.key); + if (!prev) { + added++; + push(s.group, { kind: "added", key: s.key, after: s.value }); + } else if (prev.value !== s.value) { + changed++; + push(s.group, { + kind: "changed", + key: s.key, + before: prev.value, + after: s.value, + }); + } else { + unchanged++; + unchangedBlocks.add(s.block); + } + } + for (const s of old.values()) { + if (now.has(s.key)) continue; + removed++; + push(s.group, { kind: "removed", key: s.key, before: s.value }); + } + + const groups = [...byGroup] + .map(([name, changes]) => ({ name, changes })) + .sort((a, b) => order.get(a.name)! - order.get(b.name)!); + return { + groups, + added, + changed, + removed, + unchanged, + unchangedGroups: unchangedBlocks.size, + }; +} + +/** + * A value as the project's Values setting shows it, as [head, mask, tail]: Peek keeps up to 4 + * characters at each end around a fixed mask, Hidden is a fixed mask, Visible is the value. + * Never reveals the length unless the value is Visible. + */ +export function shownValue( + value: string, + revealOn: ProjectRevealOn, +): [string, string, string] { + if (revealOn === "always") return [oneLine(value), "", ""]; + const p = revealOn === "hover" ? peek(value) : null; + return p ? [p.head, "••••••", p.tail] : ["", "••••••••", ""]; +} + +/** One line of the document; a secret, multiline or not, is one line showing its value as set. */ +interface DiffLine { + /** Exact source text, what the diff compares. */ + real: string; + shown: string; +} + +function diffLines(text: string, revealOn: ProjectRevealOn): DiffLine[] { + const doc = parseEnvDoc(text); + const secretAt = new Map(doc.secrets.map((s) => [s.start, s])); + const out: DiffLine[] = []; + for (let i = 0; i < doc.lines.length; i++) { + const line = doc.lines[i]!; + const s = secretAt.get(i); + if (!s) { + out.push({ real: line, shown: line }); + continue; + } + const real = doc.lines.slice(s.start, s.end + 1).join("\n"); + out.push({ + real, + shown: + revealOn === "always" + ? real + : line.slice(0, line.indexOf("=") + 1) + + shownValue(s.value, revealOn).join(""), + }); + i = s.end; + } + return out; +} + +/** + * Line diff for the ".env diff" view, `+`/`-`/` ` prefixed with 4 lines of context and "⋯" for + * the gaps. Lines are compared by their real text but show values per the Values setting, so a + * masked line never depends on the value's length and a change hidden by the mask still shows. + */ +export function envDiff( + before: string | null, + after: string, + revealOn: ProjectRevealOn, +): string { + const next = diffLines(after, revealOn); + const rows = + before === null + ? next.map((l) => ({ mark: "+", text: l.shown })) + : diffArrays(diffLines(before, revealOn), next, { + comparator: (a, b) => a.real === b.real, + }).flatMap((c) => + c.value.map((l) => ({ + mark: c.added ? "+" : c.removed ? "-" : " ", + text: l.shown, + })), + ); + const changed = rows.map((r) => r.mark !== " "); + const out: string[] = []; + let gap = false; + rows.forEach((row, i) => { + if (!changed.slice(Math.max(0, i - 4), i + 5).some(Boolean)) { + gap = out.length > 0; + return; + } + if (gap) out.push(" ⋯"); + gap = false; + for (const line of row.text.split("\n")) out.push(row.mark + line); + }); + return out.join("\n"); +} diff --git a/apps/web/src/lib/history/format.ts b/apps/web/src/lib/history/format.ts new file mode 100644 index 00000000..0c5c8a76 --- /dev/null +++ b/apps/web/src/lib/history/format.ts @@ -0,0 +1,54 @@ +/** Local calendar day, `YYYY-MM-DD`. */ +export function dayKey(value: string | Date): string { + const d = new Date(value); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function fromKey(key: string) { + const [y, m, d] = key.split("-").map(Number); + return new Date(y!, m! - 1, d!); +} + +/** "Oct 8", with the year when it isn't this year. */ +export function shortDay(key: string): string { + const date = fromKey(key); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: + date.getFullYear() === new Date().getFullYear() ? undefined : "numeric", + }); +} + +/** "Today", "Yesterday", else `shortDay`. */ +export function dayLabel(key: string, now: number): string { + if (key === dayKey(new Date(now))) return "Today"; + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + return key === dayKey(yesterday) ? "Yesterday" : shortDay(key); +} + +/** "now", "12m", "2h" today; "18:42" before. */ +export function shortTime(value: string, now: number): string { + if (dayKey(value) !== dayKey(new Date(now))) return clock(value); + const minutes = Math.floor((now - Date.parse(value)) / 60_000); + if (minutes < 1) return "now"; + return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`; +} + +/** "Oct 12, 2026 · 11:01". */ +export function fullDate(value: string): string { + const date = new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + return `${date} · ${clock(value)}`; +} + +function clock(value: string) { + return new Date(value).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/apps/web/src/lib/history/history.svelte.ts b/apps/web/src/lib/history/history.svelte.ts new file mode 100644 index 00000000..20f2a3eb --- /dev/null +++ b/apps/web/src/lib/history/history.svelte.ts @@ -0,0 +1,149 @@ +import { browser } from "$app/environment"; +import { SymmetricCrypto } from "$lib/auth/symmetric-crypto"; +import { getUnlockedProjectKey } from "$lib/projects/project-key"; +import { ProjectsApi, type Project } from "$lib/projects/projects.api"; +import { auth } from "$lib/stores/auth.svelte"; +import { keyAuth } from "$lib/stores/key.svelte"; +import { diffSecrets, parseEnv, type SecretDiff } from "./diff"; + +export interface HistoryVersion { + id: string; + createdAt: string; + updatedAt: string; + /** Missing on the version Cryptly created together with the project. */ + author?: { + id: string; + displayName: string; + avatarUrl?: string; + email?: string; + }; + /** Decrypted .env text of this version. In memory only, never sent anywhere. */ + content: string; + /** Decrypted text of the version before it; null for the oldest kept version. */ + previous: string | null; + /** Per-secret changes against `previous` (everything added for the oldest version). */ + diff: SecretDiff; +} + +class HistoryStore { + projectId = $state(null); + /** Newest first. Raw: replaced as a whole, never mutated in place. */ + versions = $state.raw([]); + loading = $state(false); + /** The browser is locked, so nothing can be decrypted. */ + locked = $state(false); + message = $state(null); +} + +/** + * Decrypted version history of the open project, shared by the History tab, the GitHub sync + * status in Settings, and the Secrets tab (which reloads it after a save). Decryption happens + * in the browser; the plaintext is dropped as soon as the browser locks. + */ +export const history = new HistoryStore(); + +let seq = 0; + +export async function loadHistory(project: Project): Promise { + const mySeq = ++seq; + const revision = keyAuth.revision; + const current = () => + mySeq === seq && revision === keyAuth.revision && keyAuth.hasMasterKey; + + if (history.projectId !== project.id) { + history.projectId = project.id; + history.versions = []; + } + if (!keyAuth.hasMasterKey) { + dropDecrypted(); + return; + } + history.loading = true; + history.locked = false; + history.message = null; + + try { + const jwt = auth.jwtToken; + const userId = auth.userData?.id; + if (!jwt || !userId) throw new Error("Signed out"); + if (!project.encryptedSecretsKeys?.[userId]) { + history.versions = []; + history.message = "You don't have a key for this project."; + return; + } + const encrypted = await ProjectsApi.getProjectVersions(jwt, project.id); + if (!current()) return; + const key = await getUnlockedProjectKey(project); + if (!current()) return; + if (!key) { + dropDecrypted(); + return; + } + const contents = await Promise.all( + encrypted.map((v) => + SymmetricCrypto.decryptWithKey(v.encryptedSecrets, key).catch(() => ""), + ), + ); + if (!current()) return; + + // Oldest first, so each version diffs against the one before it. + const chronological = encrypted + .map((v, i) => ({ ...v, content: contents[i]! })) + .reverse(); + const parsed = chronological.map((v) => parseEnv(v.content)); + history.versions = chronological + .map( + (v, i): HistoryVersion => ({ + id: v.id, + createdAt: v.createdAt, + updatedAt: v.updatedAt, + author: + i === 0 && isCreation(v.createdAt, project) ? undefined : v.author, + content: v.content, + previous: chronological[i - 1]?.content ?? null, + diff: diffSecrets(parsed[i - 1] ?? [], parsed[i]!), + }), + ) + .reverse(); + } catch { + if (!current()) return; + history.versions = []; + history.message = "Check your connection and try again."; + } finally { + if (mySeq === seq) history.loading = false; + } +} + +export function clearHistory() { + seq++; + history.projectId = null; + history.versions = []; + history.loading = false; + history.locked = false; + history.message = null; +} + +function dropDecrypted() { + seq++; + history.versions = []; + history.loading = false; + history.locked = true; + history.message = null; +} + +/** + * The oldest version is the project's first one (shown as created by Cryptly) unless older + * versions were pruned (the backend keeps the last 100), which shows as a later timestamp. + */ +function isCreation(createdAt: string, project: Project) { + return Date.parse(createdAt) - Date.parse(project.createdAt) < 60_000; +} + +if (browser) { + // Plaintext must not outlive the unlocked browser, whichever tab is open. + $effect.root(() => { + $effect(() => { + if (!keyAuth.hasMasterKey || !auth.jwtToken) dropDecrypted(); + }); + }); +} diff --git a/apps/web/src/lib/history/ui/ActivityHeatmap.svelte b/apps/web/src/lib/history/ui/ActivityHeatmap.svelte new file mode 100644 index 00000000..8c2c7de6 --- /dev/null +++ b/apps/web/src/lib/history/ui/ActivityHeatmap.svelte @@ -0,0 +1,96 @@ + + + + {#snippet trail()} + {#if day} + + {/if} + {/snippet} + {#each cells as cell (cell.key)} + {#if cell.count > 0} + + {:else} + (hovered = cell.future ? null : cell)} + onpointerleave={() => (hovered = null)} + > + {/if} + {/each} + diff --git a/apps/web/src/lib/history/ui/HistoryView.svelte b/apps/web/src/lib/history/ui/HistoryView.svelte new file mode 100644 index 00000000..7804a598 --- /dev/null +++ b/apps/web/src/lib/history/ui/HistoryView.svelte @@ -0,0 +1,520 @@ + + + + +{#snippet who(v: HistoryVersion)} + {#if v.author} + + {v.author.displayName} + {#if v.author.id === me} + you + {/if} + {:else} + + Cryptly + {/if} +{/snippet} + +{#snippet counts(diff: SecretDiff)} + + {#if diff.added}+{diff.added}{/if} + {#if diff.changed}~{diff.changed}{/if} + {#if diff.removed}−{diff.removed}{/if} + {#if !diff.added && !diff.changed && !diff.removed}±0{/if} + +{/snippet} + +{#snippet secretValue(value: string)} + {@const [head, mask, tail] = shownValue(value, revealOn)} + {head}{mask}{tail} +{/snippet} + +{#snippet message(title: string, text?: string, retry = false)} +
+
+

{title}

+ {#if text}

{text}

{/if} + {#if retry} + + {/if} +
+
+{/snippet} + +{#snippet filters(className: string)} + +
+ +{/snippet} + + +
{@render filters("")}
+
+
+ {@render filters("w-auto flex-1")} +
+ +{#if locked} + {@render message( + "Unlock to view history", + "Versions are encrypted. Unlock this browser to decrypt them here.", + )} +{:else if history.message && history.projectId === project.id} + {@render message("Couldn't load the history", history.message, true)} +{:else if loading} + {@render message("Loading history…")} +{:else if versions.length === 0} + {@render message("No versions yet", "Save secrets to start the history.")} +{:else} +
+
+ {#each days as group (group.key)} + + {#each group.versions as v (v.id)} + {@const on = v.id === selected?.id} + + {/each} + + {:else} +
+

No versions match.

+ +
+ {/each} + +
+ +
+ { + day = key; + syncUrl(); + }} + /> +
+
+ + {#if selected} +
+
+ + {@render who(selected)} + + {fullDate(selected.createdAt)} + {@render counts(selected.diff)} +
+ localStorage.setItem(DIFF_MODE_KEY, mode)} + /> + {#if !readOnly && selected.id !== versions[0]?.id} + + {/if} +
+ + {#if envText !== null} + + + + {:else} +
+ {#each selected.diff.groups as group (group.name)} + + {#each group.changes as change (change.key)} + {@const tone = TONE[change.kind]} +
+ {tone.mark} + {change.key} + + + {#if change.kind === "added"} + {@render secretValue(change.after ?? "")} + {:else if change.kind === "removed"} + {@render secretValue(change.before ?? "")} + {:else if revealOn === "never"} + value changed + {:else} + {@render secretValue(change.before ?? "")} + → + {@render secretValue(change.after ?? "")} + {/if} + +
+ {/each} +
+ {:else} +

+ No secrets changed. The .env diff shows comment and order + changes. +

+ {/each} + {#if selected.diff.unchanged} +

+ {plural(selected.diff.unchanged, "unchanged secret")} in {plural( + selected.diff.unchangedGroups, + "group", + )} +

+ {/if} +
+ {/if} +
+ {/if} +
+{/if} + + +
+ + +
+
diff --git a/apps/web/src/lib/integrations/ui/GithubSyncToast.svelte b/apps/web/src/lib/integrations/ui/GithubSyncToast.svelte deleted file mode 100644 index 29f548b4..00000000 --- a/apps/web/src/lib/integrations/ui/GithubSyncToast.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -
-
- {#if ok} - - {:else} - - {/if} -
- {ok ? "Synced with" : "Failed to sync with"} - - - GitHub - -
- -
-
diff --git a/apps/web/src/lib/invitations/ui/PersonalInvitationsInbox.svelte b/apps/web/src/lib/invitations/ui/PersonalInvitationsInbox.svelte deleted file mode 100644 index dc03d141..00000000 --- a/apps/web/src/lib/invitations/ui/PersonalInvitationsInbox.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - -{#if shouldShow && invitations.length > 0} -
-
-

Project invitations

-

- You have pending direct invitations. -

-
-
- {#each invitations as invitation (invitation.id)} -
-
-
-

- {invitation.projectName} -

-

- Invited by {invitation.author.displayName || - invitation.author.email || - "a teammate"} as {invitation.role} -

-
-
-
- - -
-
- {/each} -
-
-{/if} diff --git a/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte b/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte index 35a6281f..de9eda20 100644 --- a/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte +++ b/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte @@ -1,14 +1,6 @@ -{#if open} -
- + + diff --git a/apps/web/src/lib/paths.ts b/apps/web/src/lib/paths.ts new file mode 100644 index 00000000..19d068a7 --- /dev/null +++ b/apps/web/src/lib/paths.ts @@ -0,0 +1,22 @@ +import type { ProjectTab } from "$lib/projects/project-tab"; + +/** + * In-app paths for project tabs and settings sections. Wrap them in `resolve()` from + * `$app/paths` at the call site (`href={resolve(projectPath(id, "history"))}`); that's what + * adds the base path and what the svelte/no-navigation-without-resolve lint looks for. + */ +export function projectPath( + projectId: string, + tab: ProjectTab = "secrets", + section?: string, +): `/app/project/${string}` { + const path = `/app/project/${encodeURIComponent(projectId)}${tab === "secrets" ? "" : `/${tab}`}`; + return `${path}${section ? `#${section}` : ""}` as `/app/project/${string}`; +} + +/** Account settings, optionally at a section (`#devices`). */ +export function settingsPath(section?: string): "/app/settings" { + return ( + section ? `/app/settings#${section}` : "/app/settings" + ) as "/app/settings"; +} diff --git a/apps/web/src/lib/projects/create-project.ts b/apps/web/src/lib/projects/create-project.ts new file mode 100644 index 00000000..c24c6f40 --- /dev/null +++ b/apps/web/src/lib/projects/create-project.ts @@ -0,0 +1,78 @@ +import { goto } from "$app/navigation"; +import { resolve } from "$app/paths"; +import { toast } from "svelte-sonner"; +import { track } from "$lib/analytics/logdash"; +import { IntegrationsApi } from "$lib/api/integrations.api"; +import { AsymmetricCrypto } from "$lib/auth/asymmetric-crypto"; +import type { ProjectSettings } from "$lib/auth/domain/project-settings"; +import { SymmetricCrypto } from "$lib/auth/symmetric-crypto"; +import { UserApi } from "$lib/auth/user.api"; +import { projectPath } from "$lib/paths"; +import { ProjectsApi, type Project } from "$lib/projects/projects.api"; +import { upsertProject } from "$lib/shell/shell.svelte"; +import { auth, setUserData } from "$lib/stores/auth.svelte"; + +const STARTER_CONTENT = `# App\nAPI_KEY="your-value-here"\nDATABASE_URL="postgres://..."`; + +/** + * Creates a project end to end encrypted: a fresh AES project key encrypts the starter content + * in the browser, and the key is wrapped with the user's public key, so the server only ever + * stores ciphertext. Optionally connects a GitHub repository, remembers the settings as the + * user's defaults, adds the project to the sidebar and opens it. + */ +export async function createEncryptedProject({ + name, + settings, + repo, + replaceState = false, +}: { + name: string; + settings: ProjectSettings; + repo?: { id: number; installationEntityId: string }; + replaceState?: boolean; +}): Promise { + const jwt = auth.jwtToken; + const user = auth.userData; + if (!jwt || !user?.publicKey) { + throw new Error("Finish passphrase setup first"); + } + + const projectKey = await SymmetricCrypto.generateProjectKey(); + const encryptedSecrets = await SymmetricCrypto.encrypt( + STARTER_CONTENT, + projectKey, + ); + const wrappedKey = await AsymmetricCrypto.encrypt(projectKey, user.publicKey); + + const created = await ProjectsApi.createProject(jwt, { + name, + encryptedSecrets, + encryptedSecretsKeys: { [user.id]: wrappedKey }, + settings, + }); + track("project_created", { github_repo: Boolean(repo) }); + + if (repo) { + try { + await IntegrationsApi.createIntegration(jwt, { + projectId: created.id, + repositoryId: repo.id, + installationEntityId: repo.installationEntityId, + }); + } catch { + toast.error("Project created, but connecting the repository failed"); + } + } + + if (user.projectCreationDefaults?.revealOn !== settings.revealOn) { + UserApi.updateMe(jwt, { projectCreationDefaults: settings }) + .then((updated) => { + if (auth.jwtToken === jwt) setUserData(updated); + }) + .catch(() => undefined); + } + + upsertProject(created); + await goto(resolve(projectPath(created.id)), { replaceState }); + return created; +} diff --git a/apps/web/src/lib/projects/project-key.ts b/apps/web/src/lib/projects/project-key.ts new file mode 100644 index 00000000..6abbbe32 --- /dev/null +++ b/apps/web/src/lib/projects/project-key.ts @@ -0,0 +1,40 @@ +import { AsymmetricCrypto } from "$lib/auth/asymmetric-crypto"; +import { keystore } from "$lib/auth/keystore"; +import { SymmetricCrypto } from "$lib/auth/symmetric-crypto"; +import { auth } from "$lib/stores/auth.svelte"; +import { keyAuth } from "$lib/stores/key.svelte"; + +/** + * The project's AES key, unwrapped in the browser with the user's private key and cached in + * the keystore. Null while the browser is locked, when the user has no wrapped key for the + * project, or when the lock state changed mid-way (the caller must drop any decrypted state). + */ +export async function getUnlockedProjectKey(project: { + id: string; + encryptedSecretsKeys?: Record; +}): Promise { + const expectedRevision = keyAuth.revision; + const stillUnlocked = () => + expectedRevision === keyAuth.revision && keyAuth.hasMasterKey; + if (!keyAuth.hasMasterKey) return null; + + let projectKey = await keystore.getProjectKey(project.id); + if (!stillUnlocked()) return null; + if (projectKey) return projectKey; + + const userId = auth.userData?.id; + const encryptedKey = userId + ? project.encryptedSecretsKeys?.[userId] + : undefined; + const masterKey = await keystore.getMasterKey(); + if (!stillUnlocked() || !masterKey || !encryptedKey) return null; + + const projectKeyB64 = await AsymmetricCrypto.decryptWithKey( + encryptedKey, + masterKey, + ); + projectKey = await SymmetricCrypto.importAesKey(projectKeyB64); + if (!stillUnlocked()) return null; + await keystore.setProjectKey(project.id, projectKey); + return projectKey; +} diff --git a/apps/web/src/lib/projects/project-tab.ts b/apps/web/src/lib/projects/project-tab.ts new file mode 100644 index 00000000..575e17bd --- /dev/null +++ b/apps/web/src/lib/projects/project-tab.ts @@ -0,0 +1,7 @@ +export type ProjectTab = "secrets" | "history" | "settings"; + +export const PROJECT_TABS: readonly { id: ProjectTab; label: string }[] = [ + { id: "secrets", label: "Secrets" }, + { id: "history", label: "History" }, + { id: "settings", label: "Settings" }, +]; diff --git a/apps/web/src/lib/projects/ui/DiffEditor.svelte b/apps/web/src/lib/projects/ui/DiffEditor.svelte index 089454f4..a44ba589 100644 --- a/apps/web/src/lib/projects/ui/DiffEditor.svelte +++ b/apps/web/src/lib/projects/ui/DiffEditor.svelte @@ -1,213 +1,81 @@ -
+
+ + diff --git a/apps/web/src/lib/projects/ui/ProjectPage.svelte b/apps/web/src/lib/projects/ui/ProjectPage.svelte index 09ab2e18..d56c7e23 100644 --- a/apps/web/src/lib/projects/ui/ProjectPage.svelte +++ b/apps/web/src/lib/projects/ui/ProjectPage.svelte @@ -1,4832 +1,75 @@ -{#snippet connectionIcon(className: string)} - - - - -{/snippet} - -{#snippet wizardStepper(current: number, total: number)} -
- {#each Array.from({ length: total }, (_, index) => index + 1) as step (step)} - - {/each} -
-{/snippet} - -{#snippet hoverTip(text: string, extra = "")} - - {text} - -{/snippet} - -{#snippet userMenuContent(mobile: boolean)} - {#if mobile} -
-

- {auth.userData?.displayName || auth.userData?.email || "User"} -

- {#if auth.userData?.email} -

- {auth.userData.email} -

- {/if} -
-
- {/if} - {#if editingDisplayName} -
- -
- - { - if (event.key === "Enter") void saveDisplayName(); - if (event.key === "Escape") cancelDisplayNameEdit(); - }} - /> - - -
-
- {:else} - - {/if} -
- -{/snippet} - - { - if (event.key !== "Escape") return; - if (showUserMenu) closeUserMenu(); - else if (showCreateDialog) closeCreateDialog(); - else if (showIntegrationDialog) closeIntegrationDialog(); - else if (showAddPeopleDialog) closeAddPeopleDialog(); - else if (selectedMember) closeMemberDialog(); - }} -/> - -
- - + + {project ? `${project.name} · Cryptly` : "Cryptly"} + -
- {#if isSearching} -
-
- -
- - - -
- {#if !searchableProjectsLoading} - - {searchResults.length} result{searchResults.length !== 1 - ? "s" - : ""} - - {/if} -
-
- -
- {#if searchableProjectsLoading} -
-
-

Searching...

-
- {:else if searchResults.length === 0} -
- -

No projects found

-

- Try a different search term -

-
- {:else} -
- {#each searchResults as result (result.id)} - {@const mobileSnippet = contentSnippet( - result.decryptedContent, - searchQuery, - 20, - 40, - )} - {@const desktopSnippet = contentSnippet( - result.decryptedContent, - searchQuery, - 30, - 50, - )} - openSearchResult(event, result.id)} - class="group flex cursor-pointer items-start gap-3 rounded-lg border border-border/50 p-3 transition-colors active:bg-neutral-800 md:p-4 md:hover:bg-neutral-800" - > -
- -
-
-

- {result.name} -

- {#if mobileSnippet} -

- {mobileSnippet} -

- - {/if} -
-
- {/each} -
- {/if} -
- {/if} -
-
- Cryptly -
- - - -
- -
- - {#if showUserMenu} - - - {/if} -
-
-
-
-
- {#each mobileTabs as tab (tab.id)} - {@const Icon = tab.icon} - - {/each} -
- - -
- {#if activeProject} -
- -
- {/if} -
-
+ - - -
- {#if loading && !activeProject} - - {:else if loadError && !activeProject} -
-
-

- We couldn't finish loading -

-

{loadError}

-
- - -
-
-
- {:else} - -
- { - const jwt = auth.jwtToken; - if (jwt && activeProject) void loadHistory(jwt, activeProject); - }} - onConnectIntegrations={() => setActiveTab("integrations")} - /> -
- {#if activeTab === "history"} -
- {#if historyLoading} -
- Loading history... -
- {:else if historyLocked} -
-
-

- Unlock your safe to view history -

-

{historyLoadMessage}

-

- Your account session is still active. Enter your Cryptly - passphrase in the unlock dialog to continue. -

-
-
- {:else if historyLoadMessage} -
-
-

{historyLoadMessage}

- -
-
- {:else if historyVersions.length === 0} -
-
-

No history available yet.

-

Make changes to see version history.

-
-
- {:else if mobileQuery.current} -
-
- {#each historyVersions as version (version.id)} - {@const isSelected = selectedHistory?.id === version.id} - {@const volume = version.additions + version.deletions} - {@const addRatio = version.additions / Math.max(volume, 1)} - {@const barWidthPct = - (volume / - Math.max( - ...historyVersions.map( - (v) => v.additions + v.deletions, - ), - 1, - )) * - 100} - - {/each} -
-
- {#if selectedHistory} -
- {#key selectedHistory.id} - - {/key} -
- {:else} -
-
- Select a version to see changes -
-
- {/if} -
-
- {:else} -
-
-
- {#if historyMode} - {@const activeSuggestion = historySearchSuggestions.find( - (item) => item.mode === historyMode, - )} - - {:else} - - {/if} - { - historySuggestionIndex = 0; - if (historyQuery.trim().length === 0) { - historyMode = null; - historySuggestionsOpen = false; - } else if (!historyMode) { - historySuggestionsOpen = true; - } - }} - onfocus={() => { - if (historyQuery.trim() && !historyMode) - historySuggestionsOpen = true; - }} - /> -
- {#if historyQuery || historyMode} - - {:else} - / - {/if} -
-
- {#if historyQuery.trim() && historySuggestionsOpen && !historyMode} -
-
- What do you mean by "{historyQuery.trim()}"? -
-
- {#each historySearchSuggestions as suggestion, index (suggestion.mode)} - {@const SuggestionIcon = suggestion.icon} - {@const isHighlighted = - index === historySuggestionIndex} - - {/each} -
-
-
- - ↑ - ↓ - navigate - - - ↵ - apply - - - esc - dismiss - -
-
-
- {/if} -
- -
-
- {#if selectedHistoryDay} - - {/if} - {#each [{ key: "all", label: "All" }, { key: "24h", label: "-24h" }, { key: "7d", label: "-7d" }, { key: "30d", label: "-30d" }] as range (range.key)} - - {/each} -
- -
-
- {#each visibleHistoryAuthors as author (author.id)} - - {/each} -
- {#if overflowHistoryAuthors.length > 0} -
- - {#if historyAuthorsOverflowOpen} - -
-
- {#each overflowHistoryAuthors as author (author.id)} - - {/each} -
-
- {/if} -
- {/if} -
-
- -
- {#if filteredHistory.length === 0} -
- No results -
- {:else} - {#each filteredHistory as version (version.id)} - {@const isSelected = selectedHistory?.id === version.id} - {@const volume = version.additions + version.deletions} - {@const addRatio = - version.additions / Math.max(volume, 1)} - {@const barWidthPct = (volume / maxHistoryVolume) * 100} - - {/each} - {/if} -
- -
- (selectedHistoryDay = key)} - /> -
- -
-
- - ↑ - ↓ - · - j - k - navigate - - - / - search - -
- {filteredHistory.length}/{historyVersions.length} -
-
- -
- {#if selectedHistory} -
- -
-
- {selectedHistory.author?.displayName || - "System"} - {#if selectedHistory.author?.id === auth.userData?.id} - · you - {/if} - edited - - {getCompactRelativeTime(selectedHistory.createdAt)} - -
-
- {formatFullDate(selectedHistory.createdAt)} -
-
-
- - - {selectedHistory.additions} - - - - {selectedHistory.deletions} - -
-
-
- {#key selectedHistory.id} - - {/key} -
- {:else} -
- Pick an entry to view its changes -
- {/if} -
- {/if} -
- {:else if activeTab === "members"} -
-
-
-
-

Members

-

- Manage who has access to {activeProject?.name} -

-
- {#if isAdmin} - - {/if} -
-
-
- -

Members

-
-
- {#each activeProject?.members ?? [] as member (member.id)} - {@const meta = - roleMeta[member.role as keyof typeof roleMeta] ?? - roleMeta.read} - {@const RoleIcon = meta.icon} - - {/each} -
-
- {#if isAdmin && activeInvites.length > 0} -
-
- -

Active invites

-
-
- {#each activeInvites as invite (`${invite.type}-${invite.data.id}`)} -
-
- {#if invite.type === "personal"} - {#if invite.data.invitedUser.avatarUrl} - {invite.data.invitedUser.displayName} - {:else} - {(invite.data.invitedUser.displayName || "U") - .charAt(0) - .toUpperCase()} - {/if} - {:else} - - {/if} -
-
-
- {invite.type === "personal" - ? invite.data.invitedUser.displayName - : "Invite link"} -
-
- {#if invite.type === "personal"} - {invite.data.role} · {getRelativeTime( - invite.data.createdAt, - )} - {:else} - ID: {invite.data.id.slice(-8)} - {/if} -
-
-
- {#if invite.type === "link"} - {@const inviteId = invite.data.id} - - {/if} - -
-
- {/each} -
-
- {/if} -
-
- {:else if activeTab === "integrations"} -
-
-
-

Sync externally

-

- Integrate your {activeProject?.name} secrets with Github Actions. -

-
-
- {#if integrations.length > 0} -
- {#each integrations as integration (integration.id)} - {@const repoHref = - integration.repositoryData?.owner && - integration.repositoryData?.name - ? `https://github.com/${integration.repositoryData.owner}/${integration.repositoryData.name}` - : null} -
-
- {#if integration.repositoryData?.avatarUrl} - {integration.repositoryData.owner} - {:else} - - {/if} -
-
-
- {integration.repositoryData?.name ?? "Unknown"} -
-
- {integration.repositoryData?.owner ?? ""} -
-
-
- {#if repoHref} - - - Open - - {/if} - {#if isAdmin} - - {:else} - - {@render connectionIcon("size-4")} - - {/if} -
-
- {/each} -
- {/if} - {#if isAdmin && !integrationsLoading} - - {:else if !integrationsLoading && integrations.length === 0} -
- No repositories connected yet -
- {/if} -
- {#if !integrationsLoading && integrations.length === 0 && suggestedIntegrations.length > 0} -
-
- - Suggested -
-

- We found these repositories on your GitHub account based on - your Cryptly project name. -

-
- {#each suggestedIntegrations as repo (repo.id)} - - {/each} -
-
- {/if} - {#if integrations.length > 0} -
- -

- Hit - - Push - - in the - - - Editor - - to sync your secrets with GitHub Actions. -

-
- {/if} -
-
- {:else if activeTab === "settings"} -
-
-
-

Settings

-

- Manage settings for {activeProject?.name} -

-
- -
-
- -

Rename project

-
- {#if !isAdmin} -
-
- Only Admins can rename - projects. -
-
- {:else if showRenameForm} -
- - { - if (event.key === "Enter") { - event.preventDefault(); - void renameProject(); - } - }} - /> -
- - -
-
- {:else} -
-
-
-
- {activeProject?.name} -
-
- -
-
- {/if} -
- -
-
- -

Reveal on

-
- {#if !isAdmin} -
-
- Only Admins can change - project reveal settings. -
-
- {:else if revealEditing} -
- -
- - -
-
- {:else} - {@const revealMeta = - REVEAL_ON_OPTIONS.find( - (option) => option.key === revealOnSetting, - ) ?? REVEAL_ON_OPTIONS[1]} - {@const RevealIcon = revealMeta.icon} -
-
- -
-
- {revealMeta.label} -
-
- {revealMeta.description} -
-
- -
-
- {/if} -
- -
-
- -

Danger zone

-
- {#if showDeleteConfirm} -
-
- {isAdmin - ? "Are you sure you want to delete this project?" - : "Are you sure you want to leave this project?"} -
-
- {isAdmin - ? "This action cannot be undone. This will permanently delete the project and remove all members' access." - : "You will lose access to this project and its secrets. You'll need a new invitation to rejoin."} -
-
- - -
-
- {:else} -
-
-
-
- {isAdmin ? "Delete project" : "Leave project"} -
-
- {isAdmin - ? "Permanently delete this project for all members" - : "Remove yourself from this project"} -
-
- -
-
- {/if} -
-
-
- {/if} - {/if} -
-
-
- -{#if selectedMember} - - -{/if} - -{#if showAddPeopleDialog} - -