Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/extension/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# WXT
.output
.wxt
stats.html
node_modules
*SPEC.MD
40 changes: 40 additions & 0 deletions apps/extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# @scrt-link/extension

Browser extension (Chrome + Firefox, MV3/MV2) that creates an encrypted,
one-time-view secret link in one click via [scrt.link](https://scrt.link).

It's a thin UI over [`@scrt-link/client`](../../packages/client) — all
client-side encryption and the API call live there. The extension only adds the
popup UI and API-key storage. See [SPEC.md](./SPEC.md) for scope and decisions.

## Auth

The extension authenticates with a personal **API key** (created at
`scrt.link/account/api`). There is no anonymous mode. API access requires a plan
that includes it, so the extension is effectively a feature for those plans.

## Development

```bash
# from repo root
pnpm --filter @scrt-link/extension dev # Chrome, with HMR
pnpm --filter @scrt-link/extension dev:firefox # Firefox
```

`wxt dev` launches a browser with the extension loaded. To load a production
build manually instead:

```bash
pnpm --filter @scrt-link/extension build
# Chrome: chrome://extensions → enable Developer mode → Load unpacked →
# select apps/extension/.output/chrome-mv3
```

## Build / package

```bash
pnpm --filter @scrt-link/extension build # → .output/chrome-mv3
pnpm --filter @scrt-link/extension build:firefox # → .output/firefox-mv2
pnpm --filter @scrt-link/extension zip # store-ready .zip
pnpm --filter @scrt-link/extension check # svelte-check / types
```
46 changes: 46 additions & 0 deletions apps/extension/entrypoints/popup/App.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script lang="ts">
import CreateSecret from './lib/CreateSecret.svelte';
import KeyEntry from './lib/KeyEntry.svelte';
import { getApiKey } from './lib/storage';

let apiKey = $state<string | null>(null);
let loading = $state(true);
let managingKey = $state(false);

$effect(() => {
getApiKey().then((key) => {
apiKey = key;
loading = false;
});
});

function onKeySaved(key: string) {
apiKey = key;
managingKey = false;
}

function onKeyCleared() {
apiKey = null;
managingKey = false;
}
</script>

<main>
{#if loading}
<div class="screen center">
<p class="muted">Loading…</p>
</div>
{:else if !apiKey}
<KeyEntry mode="onboard" onSaved={onKeySaved} />
{:else if managingKey}
<KeyEntry
mode="edit"
currentKey={apiKey}
onSaved={onKeySaved}
onCleared={onKeyCleared}
onCancel={() => (managingKey = false)}
/>
{:else}
<CreateSecret {apiKey} onManageKey={() => (managingKey = true)} />
{/if}
</main>
12 changes: 12 additions & 0 deletions apps/extension/entrypoints/popup/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>scrt.link</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
159 changes: 159 additions & 0 deletions apps/extension/entrypoints/popup/lib/CreateSecret.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<script lang="ts">
import scrtLink, { type Options } from '@scrt-link/client';

import { ACCOUNT_API_URL, DEFAULT_EXPIRY, EXPIRY_OPTIONS } from './constants';
import Logo from './Logo.svelte';

type Props = {
apiKey: string;
onManageKey: () => void;
};

let { apiKey, onManageKey }: Props = $props();

let secret = $state('');
let password = $state('');
let publicNote = $state('');
let expiresIn = $state(DEFAULT_EXPIRY);
let viewLimit = $state(1);
let showOptions = $state(false);

let loading = $state(false);
let resultLink = $state('');
let error = $state('');
let copied = $state(false);

// Auth/plan failures should point the user at their account, not just show a raw error.
const isAuthError = $derived(/api key|api access|premium|bearer token/i.test(error));

async function create() {
error = '';
if (!secret.trim()) {
error = 'Please enter a secret to share.';
return;
}

loading = true;
try {
const options: Partial<Options> = {
expiresIn,
viewLimit,
...(password ? { password } : {}),
...(publicNote ? { publicNote } : {})
};
const result = await scrtLink(apiKey).createSecret(secret, options);

if (result && 'secretLink' in result && result.secretLink) {
resultLink = result.secretLink;
} else {
error = result?.error || 'Something went wrong. Please try again.';
}
} catch {
error = 'Network error — could not reach scrt.link. Please try again.';
} finally {
loading = false;
}
}

async function copy() {
await navigator.clipboard.writeText(resultLink);
copied = true;
setTimeout(() => (copied = false), 1500);
}

function reset() {
resultLink = '';
secret = '';
password = '';
publicNote = '';
error = '';
}
</script>

<div class="screen">
<header class="row">
<div class="brand">
<Logo />
<h1>Share a secret</h1>
</div>
<button class="icon" type="button" title="API key settings" onclick={onManageKey}>⚙</button>
</header>

{#if resultLink}
<div class="result">
<p class="muted">Your one-time secret link is ready:</p>
<div class="link-box">
<input type="text" readonly value={resultLink} />
<button class="primary" type="button" onclick={copy}>{copied ? 'Copied ✓' : 'Copy'}</button>
</div>

<button class="ghost" type="button" onclick={reset}>Create another</button>
</div>
{:else}
<label class="field">
<span>Secret</span>
<textarea
rows="4"
placeholder="Type or paste the secret you want to share…"
bind:value={secret}
oninput={() => (error = '')}
></textarea>
</label>

<button class="link-button" type="button" onclick={() => (showOptions = !showOptions)}>
{showOptions ? '− Fewer options' : '+ More options'}
</button>

{#if showOptions}
<div class="options">
<div class="options-grid">
<label class="field">
<span>Expires after</span>
<select bind:value={expiresIn}>
{#each EXPIRY_OPTIONS as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</label>

<label class="field">
<span>View limit</span>
<input type="number" min="1" max="10" bind:value={viewLimit} />
</label>
</div>
<label class="field">
<span>Password (optional)</span>
<input
type="password"
placeholder="Extra protection"
bind:value={password}
autocomplete="off"
/>
</label>

<label class="field">
<span>Public note (optional)</span>
<input
type="text"
placeholder="Shown before the secret is revealed"
bind:value={publicNote}
/>
</label>
</div>
{/if}

{#if error}
<p class="error">
{error}
{#if isAuthError}
<br />
<a href={ACCOUNT_API_URL} target="_blank" rel="noreferrer">Check your API key →</a>
{/if}
</p>
{/if}

<button class="primary block" type="button" onclick={create} disabled={loading}>
{loading ? 'Encrypting…' : 'Create secret link'}
</button>
{/if}
</div>
92 changes: 92 additions & 0 deletions apps/extension/entrypoints/popup/lib/KeyEntry.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<script lang="ts">
import { ACCOUNT_API_URL } from './constants';
import Logo from './Logo.svelte';
import { clearApiKey, setApiKey } from './storage';

type Props = {
mode: 'onboard' | 'edit';
currentKey?: string | null;
onSaved: (key: string) => void;
onCleared?: () => void;
onCancel?: () => void;
};

let { mode, currentKey = null, onSaved, onCleared, onCancel }: Props = $props();

let value = $state('');
let error = $state('');
let saving = $state(false);

const maskedCurrent = $derived(
currentKey ? `${currentKey.slice(0, 6)}…${currentKey.slice(-4)}` : ''
);

async function save() {
const trimmed = value.trim();
if (!trimmed) {
error = 'Please paste your API key.';
return;
}
saving = true;
await setApiKey(trimmed);
saving = false;
onSaved(trimmed);
}

async function clear() {
saving = true;
await clearApiKey();
saving = false;
onCleared?.();
}
</script>

<div class="screen">
<header>
<div class="brand">
<Logo />
<h1>{mode === 'onboard' ? 'Connect your account' : 'API key'}</h1>
</div>
<p class="muted">
{#if mode === 'onboard'}
Paste a scrt.link API key to start sharing secrets from your browser.
{:else}
Current key: <code>{maskedCurrent}</code>
{/if}
</p>
</header>

<label class="field">
<span>{mode === 'onboard' ? 'API key' : 'Replace with a new key'}</span>
<input
type="password"
placeholder="ak_…"
bind:value
autocomplete="off"
spellcheck="false"
oninput={() => (error = '')}
/>
</label>

{#if error}
<p class="error">{error}</p>
{/if}

<p class="muted small">
Don't have a key? <a href={ACCOUNT_API_URL} target="_blank" rel="noreferrer">
Create one in your account →
</a>
</p>

<div class="actions">
{#if mode === 'edit' && onCancel}
<button class="ghost" type="button" onclick={onCancel} disabled={saving}>Cancel</button>
{/if}
{#if mode === 'edit' && currentKey}
<button class="danger" type="button" onclick={clear} disabled={saving}>Clear key</button>
{/if}
<button class="primary" type="button" onclick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save key'}
</button>
</div>
</div>
15 changes: 15 additions & 0 deletions apps/extension/entrypoints/popup/lib/Logo.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script lang="ts">
// scrt.link "flame" mark, inlined from static/logo.svg so it inherits the
// popup's brand colors (navy primary + red accent).
</script>

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680 680" aria-hidden="true">
<path
fill="var(--primary)"
d="M140.89,222.95l72.88,115.03c16.7.95,33.01,6.52,46.88,16.31l285.53-145.57-249.12,201.63c3.43,17.32,1.63,35.2-4.91,51.34l72.18,113.91,262.25-418.91-485.69,66.26Z"
/>
<path
fill="var(--accent)"
d="M56.83,526.13c-11.49-31.37,39.51-134.03,127.37-126.26-11.02-12.59-30.2-11.63-49.43-3.79,8.24-29.82,79.7-84.05,138.06-15.76-57.97-18.7-44.28,56.29-121.21,52.55,11.07,8.76,53.47,30.54,91.19-3.38,4.8,14.97-15.26,29.95-21.61,30.38,53.11,8.01,64.74-36.2,63.52-59.25,23.15,78.48-48.4,113.37-84.84,107.57-50.75-8.07-86.55-43.22-143.05,17.94Z"
/>
</svg>
16 changes: 16 additions & 0 deletions apps/extension/entrypoints/popup/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Where users generate/manage API keys.
export const ACCOUNT_API_URL = 'https://scrt.link/account/api';

// `expiresIn` is a duration in milliseconds (server does `Date.now() + expiresIn`).
const MIN = 60 * 1000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;

export const EXPIRY_OPTIONS: { label: string; value: number }[] = [
{ label: '1 hour', value: HOUR },
{ label: '1 day', value: DAY },
{ label: '1 week', value: 7 * DAY },
{ label: '1 month', value: 30 * DAY }
];

export const DEFAULT_EXPIRY = 7 * DAY;
Loading
Loading