diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 8832f71..e01475e 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -64,7 +64,7 @@ jobs:
run: |
failed=0
while IFS= read -r page; do
- if ! grep -q '` and `/join/` — the same job the Pages Functions do on
+Cloudflare — so the browser talks to one origin, exactly as in production.
+Point it elsewhere with `BACKEND_DEV_URL`.
-```bash
-curl -X POST http://localhost:8787/auth/dev \
- -H 'content-type: application/json' \
- -d '{"email":"you@example.com"}' -c cookies.txt
-```
+To try every paid feature:
+
+1. **Sign in** from the header. Locally it asks for an email instead of sending
+ you to Google — any address works, and each one is a separate account.
+2. **Unlock** any locked feature and redeem the test licence
+ **`BC-TEST-TEST-TEST`**. It is reusable, so a second account (for
+ co-organisers, say) can redeem it too.
+3. **Guests → Send invite** creates the invite link. Open it in a private
+ window to RSVP as a guest; confirm, and use _Copy my link_ to forward it and
+ see a friend-of-friend land in the spread view.
+
+The `local` Worker environment behaves like the hosted product — you sign in as
+`free` and the paywall is real. Put `SELF_HOSTED="true"` in
+`backend/.dev.vars` to see the self-hosted behaviour instead, where every
+signed-in user is `pro`.
Checks, all of which CI runs:
@@ -318,6 +331,10 @@ npm --prefix backend run licence:issue -- --env production --note "ko-fi #128"
It prints a code like `BC-7K2M-QP4X-9DNR` and inserts it into D1. The buyer
redeems it in the app, which flips their tier to `pro`.
+For testing, `BC-TEST-TEST-TEST` unlocks `pro` on the `local` and `preview`
+Workers without a row in D1 (`TEST_LICENCE_CODE` in `backend/wrangler.jsonc`).
+It is refused on production whatever the config says, since the code is public.
+
---
## Data, Persistence & Privacy
diff --git a/astro.config.mjs b/astro.config.mjs
index 1030dbc..1c8e804 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -20,7 +20,9 @@ const isDocs = target === 'docs';
const site =
process.env.SITE ??
- (isDocs ? 'https://fre0grella.github.io' : 'https://bottlecount.pages.dev');
+ (isDocs
+ ? 'https://fre0grella.github.io'
+ : 'https://bottlecount-epj.pages.dev');
const base = process.env.BASE_PATH ?? (isDocs ? '/BottleCount/' : '/');
/**
@@ -64,9 +66,71 @@ function docsOnly() {
};
}
+/**
+ * Where `astro dev` finds the Worker — `wrangler dev` in the backend's own
+ * terminal. Override with `BACKEND_DEV_URL` if it runs somewhere else.
+ */
+const backendDevUrl = process.env.BACKEND_DEV_URL ?? 'http://localhost:8787';
+
+/**
+ * What `functions/` does on Cloudflare, done by the dev server instead.
+ *
+ * The frontend only ever calls its own origin (`/api/session`, never
+ * `localhost:8787/api/session`), because in production the Pages Functions
+ * forward those paths to the Worker over a service binding. `astro dev` runs no
+ * Pages Functions, so without this every call 404s, the session resolves to
+ * anonymous, and every paid feature sits locked with no way to unlock it.
+ *
+ * `/auth/*` is listed path by path for the same reason `functions/auth/` is
+ * three files rather than a catchall: `/auth/callback` is a page, not a route.
+ */
+const DEV_PROXY_PATHS = [
+ '/api',
+ '/invite',
+ '/auth/dev',
+ '/auth/google',
+ '/auth/logout',
+];
+
+/**
+ * `/i/` and `/join/` are minted at runtime, so the build has one
+ * page for each and `functions/i/` and `functions/join/` rewrite the whole
+ * space onto it. This is that rewrite for the dev server; the browser's URL is
+ * untouched and the page still reads the slug off it.
+ */
+function devRewrites() {
+ const REWRITES = [
+ [/^\/i\/[^/?#]+/, '/i/'],
+ [/^\/join\/[^/?#]+/, '/join/'],
+ ];
+ return {
+ name: 'bottlecount:dev-rewrites',
+ apply: 'serve',
+ configureServer(server) {
+ server.middlewares.use((req, _res, next) => {
+ for (const [pattern, target] of REWRITES) {
+ if (req.url && pattern.test(req.url)) {
+ req.url = req.url.replace(pattern, target);
+ break;
+ }
+ }
+ next();
+ });
+ },
+ };
+}
+
export default defineConfig({
site,
base,
integrations: [vue(), ...(isDocs ? [docsOnly()] : [])],
output: 'static',
+ vite: {
+ plugins: [devRewrites()],
+ server: {
+ proxy: Object.fromEntries(
+ DEV_PROXY_PATHS.map((path) => [path, { target: backendDevUrl }]),
+ ),
+ },
+ },
});
diff --git a/backend/.dev.vars.example b/backend/.dev.vars.example
index 97a724d..488c6b1 100644
--- a/backend/.dev.vars.example
+++ b/backend/.dev.vars.example
@@ -9,3 +9,7 @@ JWT_SECRET="dev-only-change-me"
# POST /auth/dev instead.
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
+
+# Optional: "true" makes local behave like a self-hosted deployment, where every
+# signed-in user is pro. Leave it out to test the free tier and the upgrade.
+# SELF_HOSTED="true"
diff --git a/backend/README.md b/backend/README.md
index ebaa03c..e0df128 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -65,20 +65,32 @@ npm run db:init:local # applies migrations to the local D1
npm run dev # wrangler dev --env local
```
-The `local` environment sets `SELF_HOSTED=true`, so you can sign in without a
-Google OAuth client:
+The `local` environment behaves like the hosted product: `SELF_HOSTED` is
+`"false"`, so a new account is `free` and the paywall is real. `/auth/dev` still
+works, because `ENVIRONMENT` is `local`:
```bash
curl -X POST http://localhost:8787/auth/dev \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","name":"You"}' -c cookies.txt
+curl -X POST http://localhost:8787/api/licences/redeem \
+ -H 'content-type: application/json' \
+ -d '{"code":"BC-TEST-TEST-TEST"}' -b cookies.txt
+
curl http://localhost:8787/api/session -b cookies.txt
```
-Run the Astro dev server (`npm run dev` at the repo root) beside it. In
-production the Pages Functions proxy puts both on one origin; in development
-they are two ports, which is the only reason the CORS middleware is there.
+`BC-TEST-TEST-TEST` is the test licence (`TEST_LICENCE_CODE`, set on `local`
+and `preview`). Unlike a minted code it is reusable and never written to
+`licence_keys`, and it is refused whenever `ENVIRONMENT` is `production`. Put
+`SELF_HOSTED="true"` in `.dev.vars` to see the self-hosted behaviour instead.
+
+Run the Astro dev server (`npm run dev` at the repo root) beside it and open
+http://localhost:4321/app. It proxies `/api/*`, `/invite/*` and the `/auth/*`
+routes here (see `astro.config.mjs`), so the browser sees one origin, as it
+does behind the Pages Functions in production — and signing in from the header
+offers the same email sign-in as `/auth/dev`.
## Deploying
@@ -112,6 +124,9 @@ It prints a code such as `BC-7K2M-QP4X-9DNR` and inserts it. The buyer redeems
it in the app. `--print` generates a code and the SQL without touching the
database.
+To test the upgrade without minting anything, redeem `BC-TEST-TEST-TEST` on
+`local` or `preview` (see above).
+
## Structure
```
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 6087839..9621086 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -21,6 +21,8 @@ export type Bindings = {
ENVIRONMENT: string;
/** "true" on a self-hosted deployment — see shared/tiers.ts. */
SELF_HOSTED?: string;
+ /** A reusable licence for testing. Ignored on production — see routes/licences.ts. */
+ TEST_LICENCE_CODE?: string;
/** Guards the unauthenticated invite endpoints. Absent locally. */
INVITE_RATE_LIMITER?: {
limit(o: { key: string }): Promise<{ success: boolean }>;
@@ -47,9 +49,9 @@ export function createApp(overrides: AppOverrides = {}): App {
const app: App = new Hono<{ Bindings: Bindings; Variables: AppVariables }>();
// In production the Pages Function proxy puts the frontend and this Worker on
- // one origin, so CORS never comes up. It matters for `wrangler dev`, where
- // the Astro dev server is a different port and the session cookie has to
- // survive the hop.
+ // one origin, and in development the Astro dev server's proxy does the same
+ // (astro.config.mjs), so CORS normally never comes up. This covers a page
+ // that calls the Worker's own port directly.
app.use(
'*',
cors({
diff --git a/backend/src/routes/devAuth.ts b/backend/src/routes/devAuth.ts
index 90d20ec..18d8fc4 100644
--- a/backend/src/routes/devAuth.ts
+++ b/backend/src/routes/devAuth.ts
@@ -11,6 +11,18 @@ type Bindings = {
const devAuth = new Hono<{ Bindings: Bindings; Variables: AppVariables }>();
+/**
+ * Whether this Worker signs people in without Google. Also read by
+ * `/api/session`, so the app offers the email sign-in exactly where this route
+ * would accept it.
+ */
+export function devSignInEnabled(env: {
+ ENVIRONMENT: string;
+ SELF_HOSTED?: string;
+}): boolean {
+ return env.SELF_HOSTED === 'true' || env.ENVIRONMENT === 'local';
+}
+
/**
* Sign in without Google.
*
@@ -26,8 +38,7 @@ const devAuth = new Hono<{ Bindings: Bindings; Variables: AppVariables }>();
* 404 as though the route did not exist.
*/
devAuth.post('/dev', async (c) => {
- const enabled = c.env.SELF_HOSTED === 'true' || c.env.ENVIRONMENT === 'local';
- if (!enabled) return c.notFound();
+ if (!devSignInEnabled(c.env)) return c.notFound();
const body = await c.req
.json<{ email?: string; name?: string }>()
diff --git a/backend/src/routes/licences.ts b/backend/src/routes/licences.ts
index 730f9bc..9025dfd 100644
--- a/backend/src/routes/licences.ts
+++ b/backend/src/routes/licences.ts
@@ -1,14 +1,33 @@
import { Hono } from 'hono';
import type { JwtVariables } from 'hono/jwt';
-import { featuresFor, resolveTier } from '../../../shared/tiers';
+import { featuresFor, resolveTier, type Tier } from '../../../shared/tiers';
import type { AppVariables } from '../appEnv';
import { LICENCE_ERRORS } from '../repositories/licenceRepository';
import { userErrorStatus } from './helpers';
type Bindings = {
SELF_HOSTED?: string;
+ ENVIRONMENT: string;
+ TEST_LICENCE_CODE?: string;
};
+/**
+ * Whether `code` is this deployment's test licence.
+ *
+ * Every paid feature sits behind a redeemed code, and a minted one is spent on
+ * first use — so testing the upgrade, or a second account, would mean minting
+ * again each time. The test code is reusable, never written to `licence_keys`,
+ * and comes from a var set only on the `local` and `preview` environments.
+ *
+ * Refused on production whatever the var says: the code is in a public
+ * repository, so a copy-pasted env block must not become a free licence.
+ */
+function isTestLicence(env: Bindings, code: string): boolean {
+ if (env.ENVIRONMENT === 'production') return false;
+ const expected = env.TEST_LICENCE_CODE?.trim().toUpperCase();
+ return !!expected && code.toUpperCase() === expected;
+}
+
const licences = new Hono<{
Bindings: Bindings;
Variables: AppVariables & JwtVariables;
@@ -32,18 +51,21 @@ licences.post('/redeem', async (c) => {
const code = body.code?.trim();
if (!code) return c.json({ error: 'code is required' }, 400);
- const redeemed = await c.var.repositories.licences.redeem(code, sub);
- if (!redeemed.ok) {
- // An unknown code and a spent one answer alike: telling them apart lets
- // someone probe the keyspace for codes that merely belong to somebody else.
- const status = redeemed.error === LICENCE_ERRORS.UNKNOWN ? 404 : 409;
- return c.json({ error: redeemed.error }, status);
+ let grantedTier: Tier;
+ if (isTestLicence(c.env, code)) {
+ grantedTier = 'pro';
+ } else {
+ const redeemed = await c.var.repositories.licences.redeem(code, sub);
+ if (!redeemed.ok) {
+ // An unknown code and a spent one answer alike: telling them apart lets
+ // someone probe the keyspace for codes that merely belong to somebody else.
+ const status = redeemed.error === LICENCE_ERRORS.UNKNOWN ? 404 : 409;
+ return c.json({ error: redeemed.error }, status);
+ }
+ grantedTier = redeemed.value.tier;
}
- const updated = await c.var.repositories.users.setTier(
- sub,
- redeemed.value.tier,
- );
+ const updated = await c.var.repositories.users.setTier(sub, grantedTier);
if (!updated.ok) {
return c.json({ error: updated.error }, userErrorStatus(updated.error));
}
diff --git a/backend/src/routes/session.ts b/backend/src/routes/session.ts
index 741aefb..624ab2e 100644
--- a/backend/src/routes/session.ts
+++ b/backend/src/routes/session.ts
@@ -4,16 +4,18 @@ import { verify } from 'hono/jwt';
import type { SessionDTO } from '../../../shared/session';
import { featuresFor, resolveTier } from '../../../shared/tiers';
import type { AppVariables } from '../appEnv';
+import { devSignInEnabled } from './devAuth';
type Bindings = {
JWT_SECRET: string;
+ ENVIRONMENT: string;
SELF_HOSTED?: string;
};
const session = new Hono<{ Bindings: Bindings; Variables: AppVariables }>();
/** Anonymous free tier — what an unsigned, expired or unreadable cookie means. */
-function anonymous(selfHosted: boolean): SessionDTO {
+function anonymous(selfHosted: boolean, devSignIn: boolean): SessionDTO {
const tier = resolveTier({ storedTier: null, selfHosted });
return {
authenticated: false,
@@ -22,6 +24,7 @@ function anonymous(selfHosted: boolean): SessionDTO {
features: featuresFor(tier),
selfHosted,
backendAvailable: true,
+ devSignIn,
};
}
@@ -36,23 +39,25 @@ function anonymous(selfHosted: boolean): SessionDTO {
*/
session.get('/', async (c) => {
const selfHosted = c.env.SELF_HOSTED === 'true';
+ const devSignIn = devSignInEnabled(c.env);
const token = getCookie(c, 'session_token');
- if (!token) return c.json(anonymous(selfHosted));
+ if (!token) return c.json(anonymous(selfHosted, devSignIn));
let sub: string;
try {
const payload = await verify(token, c.env.JWT_SECRET, 'HS256');
- if (typeof payload.sub !== 'string') return c.json(anonymous(selfHosted));
+ if (typeof payload.sub !== 'string')
+ return c.json(anonymous(selfHosted, devSignIn));
sub = payload.sub;
} catch {
- return c.json(anonymous(selfHosted));
+ return c.json(anonymous(selfHosted, devSignIn));
}
// The tier comes from the row, never from the cookie: a JWT lives 7 days, and
// a claim baked into one would keep granting `pro` for a week after a refund
// — or withhold it until re-login after a purchase.
const found = await c.var.repositories.users.findById(sub);
- if (!found.ok) return c.json(anonymous(selfHosted));
+ if (!found.ok) return c.json(anonymous(selfHosted, devSignIn));
const user = found.value;
const tier = resolveTier({ storedTier: user.tier, selfHosted });
@@ -69,6 +74,7 @@ session.get('/', async (c) => {
features: featuresFor(tier),
selfHosted,
backendAvailable: true,
+ devSignIn,
};
return c.json(dto);
});
diff --git a/backend/src/tests/routes/licences.spec.ts b/backend/src/tests/routes/licences.spec.ts
index 96235dd..0fc2227 100644
--- a/backend/src/tests/routes/licences.spec.ts
+++ b/backend/src/tests/routes/licences.spec.ts
@@ -111,6 +111,88 @@ describe('POST /api/licences/redeem', () => {
});
});
+describe('the test licence', () => {
+ const TEST_CODE = 'BC-TEST-TEST-TEST';
+
+ it('upgrades the caller without a licence_keys row', async () => {
+ const users = fakeUsers([aUser({ tier: 'free' })]);
+ const res = await request('/api/licences/redeem', {
+ repositories: fakeRepositories(users, fakeLicences()),
+ env: { ENVIRONMENT: 'local', TEST_LICENCE_CODE: TEST_CODE },
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { code: TEST_CODE },
+ });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ tier: 'pro' });
+ expect(users.rows.get('user-1')?.tier).toBe('pro');
+ });
+
+ it('can be redeemed by more than one account', async () => {
+ // A co-organiser test needs two accounts, and a minted code is spent on
+ // first use — this one never is.
+ const users = fakeUsers([
+ aUser({ id: 'user-1', email: 'a@example.com' }),
+ aUser({ id: 'user-2', email: 'b@example.com' }),
+ ]);
+ const repositories = fakeRepositories(users, fakeLicences());
+ const env = { ENVIRONMENT: 'preview', TEST_LICENCE_CODE: TEST_CODE };
+
+ for (const id of ['user-1', 'user-2']) {
+ const res = await request('/api/licences/redeem', {
+ repositories,
+ env,
+ cookie: await sessionCookie(id),
+ method: 'POST',
+ body: { code: TEST_CODE },
+ });
+ expect(res.status).toBe(200);
+ }
+ expect(users.rows.get('user-2')?.tier).toBe('pro');
+ });
+
+ it('accepts it typed in lower case', async () => {
+ const res = await request('/api/licences/redeem', {
+ repositories: fakeRepositories(fakeUsers([aUser()]), fakeLicences()),
+ env: { ENVIRONMENT: 'local', TEST_LICENCE_CODE: TEST_CODE },
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { code: ' bc-test-test-test ' },
+ });
+
+ expect(res.status).toBe(200);
+ });
+
+ it('is refused on production even when the var is set', async () => {
+ // The code is in a public repository; a copied env block must not turn it
+ // into a free licence on the deployment people pay for.
+ const users = fakeUsers([aUser({ tier: 'free' })]);
+ const res = await request('/api/licences/redeem', {
+ repositories: fakeRepositories(users, fakeLicences()),
+ env: { ENVIRONMENT: 'production', TEST_LICENCE_CODE: TEST_CODE },
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { code: TEST_CODE },
+ });
+
+ expect(res.status).toBe(404);
+ expect(users.rows.get('user-1')?.tier).toBe('free');
+ });
+
+ it('does not exist where the var is unset', async () => {
+ const res = await request('/api/licences/redeem', {
+ repositories: fakeRepositories(fakeUsers([aUser()]), fakeLicences()),
+ env: { ENVIRONMENT: 'local' },
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { code: TEST_CODE },
+ });
+
+ expect(res.status).toBe(404);
+ });
+});
+
describe('the /api/* guard', () => {
it('protects everything except the session endpoint', async () => {
const repositories = fakeRepositories();
diff --git a/backend/src/tests/routes/session.spec.ts b/backend/src/tests/routes/session.spec.ts
index 9d8c400..8ee1ec3 100644
--- a/backend/src/tests/routes/session.spec.ts
+++ b/backend/src/tests/routes/session.spec.ts
@@ -81,3 +81,31 @@ describe('GET /api/session', () => {
expect(dto.selfHosted).toBe(true);
});
});
+
+describe('devSignIn', () => {
+ // The app offers an email sign-in only where POST /auth/dev would accept it;
+ // anywhere else the button would lead to a 404.
+ it('is on for local development', async () => {
+ const dto = await getSession({
+ repositories: fakeRepositories(),
+ env: { ENVIRONMENT: 'local' },
+ });
+ expect(dto.devSignIn).toBe(true);
+ });
+
+ it('is on for a self-hosted Worker', async () => {
+ const dto = await getSession({
+ repositories: fakeRepositories(),
+ env: { ENVIRONMENT: 'production', SELF_HOSTED: 'true' },
+ });
+ expect(dto.devSignIn).toBe(true);
+ });
+
+ it('is off on the hosted deployment', async () => {
+ const dto = await getSession({
+ repositories: fakeRepositories(),
+ env: { ENVIRONMENT: 'production', SELF_HOSTED: 'false' },
+ });
+ expect(dto.devSignIn).toBe(false);
+ });
+});
diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc
index a03fa78..14b83ae 100644
--- a/backend/wrangler.jsonc
+++ b/backend/wrangler.jsonc
@@ -39,9 +39,14 @@
"vars": {
"FRONTEND_URL": "http://localhost:4321",
"ENVIRONMENT": "local",
- // Unlocks POST /auth/dev so a fresh clone can sign in without
- // registering a Google OAuth client.
- "SELF_HOSTED": "true",
+ // "false" so local behaves like the hosted product: you sign in as
+ // free and the paywall is real. POST /auth/dev still works, because
+ // ENVIRONMENT is "local". To try the self-hosted behaviour instead
+ // (every signed-in user is pro), put SELF_HOSTED="true" in .dev.vars.
+ "SELF_HOSTED": "false",
+ // Reusable licence for testing — redeem it in the app to unlock pro.
+ // Refused on production whatever this says (routes/licences.ts).
+ "TEST_LICENCE_CODE": "BC-TEST-TEST-TEST",
},
},
@@ -74,9 +79,11 @@
],
},
"vars": {
- "FRONTEND_URL": "https://preview.bottlecount.pages.dev",
+ "FRONTEND_URL": "https://preview.bottlecount-epj.pages.dev",
"ENVIRONMENT": "preview",
"SELF_HOSTED": "false",
+ // Reusable licence for testing on the preview deployment.
+ "TEST_LICENCE_CODE": "BC-TEST-TEST-TEST",
"GOOGLE_CLIENT_ID": "",
},
},
@@ -87,8 +94,7 @@
{
"binding": "db",
"database_name": "db",
- // Fill in after `wrangler d1 create db`.
- "database_id": "",
+ "database_id": "c57cbc9b-0086-4d82-b7f1-b8f896bc50dd",
},
],
// Guards the two unauthenticated invite endpoints. They are the only
@@ -110,14 +116,14 @@
],
},
"vars": {
- "FRONTEND_URL": "https://bottlecount.pages.dev",
+ "FRONTEND_URL": "https://bottlecount-epj.pages.dev",
"ENVIRONMENT": "production",
// The hosted deployment is the one people pay for, so this is the one
// place it must stay "false" — see shared/tiers.ts.
"SELF_HOSTED": "false",
// Not a secret (GOOGLE_CLIENT_SECRET and JWT_SECRET are, and are set
// with `wrangler secret put`).
- "GOOGLE_CLIENT_ID": "",
+ "GOOGLE_CLIENT_ID": "402376254532-ji43fivhq1ksni3pahe1hrsblvsbsm7l.apps.googleusercontent.com",
},
},
diff --git a/package.json b/package.json
index 1abda3c..9b8ff59 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
"typecheck": "astro check && npm --prefix backend run typecheck",
"install:all": "npm install && npm --prefix backend install",
"test:frontend": "vitest run",
- "build:docs": "BUILD_TARGET=docs BASE_PATH=/BottleCount/ SITE=https://fre0grella.github.io PUBLIC_APP_ORIGIN=https://bottlecount.pages.dev astro build"
+ "build:docs": "BUILD_TARGET=docs BASE_PATH=/BottleCount/ SITE=https://fre0grella.github.io PUBLIC_APP_ORIGIN=https://bottlecount-epj.pages.dev astro build"
},
"engines": {
"node": ">=22.12.0"
diff --git a/shared/session.ts b/shared/session.ts
index b21cae4..e74aeab 100644
--- a/shared/session.ts
+++ b/shared/session.ts
@@ -26,6 +26,12 @@ export interface SessionDTO {
selfHosted: boolean;
/** False when no backend is reachable — the pure browser-only build. */
backendAvailable: boolean;
+ /**
+ * True when `POST /auth/dev` is open — local development or a self-hosted
+ * Worker — so the app can offer an email sign-in rather than sending the user
+ * to a Google client that may not be registered.
+ */
+ devSignIn: boolean;
}
export type { Feature, FeatureSet, Tier };
diff --git a/src/components/AccountButton.vue b/src/components/AccountButton.vue
index b20bdad..f6ab4f3 100644
--- a/src/components/AccountButton.vue
+++ b/src/components/AccountButton.vue
@@ -1,7 +1,7 @@
-
+
+
+
+
+
+
',
key: '',
+ download: '',
};
diff --git a/src/components/InviteScreen.vue b/src/components/InviteScreen.vue
index df65199..2a64dcd 100644
--- a/src/components/InviteScreen.vue
+++ b/src/components/InviteScreen.vue
@@ -85,7 +85,7 @@ const isDeclined = computed(() => data.value?.status === 'declined');
const forwardLink = computed(() => {
const token = data.value?.forwardToken;
if (!token || !data.value) return '';
- return inviteUrl(window.location.host, base, data.value.party.slug, token);
+ return inviteUrl(window.location.origin, base, data.value.party.slug, token);
});
/** A full party can still be declined, so the form stays — only yes is barred. */
@@ -163,7 +163,7 @@ function changeAnswer(): void {
function copyForward(): void {
navigator.clipboard
- ?.writeText(`https://${forwardLink.value}`)
+ ?.writeText(forwardLink.value)
.then(() => {
copied.value = true;
setTimeout(() => (copied.value = false), 2000);
diff --git a/src/components/modals/ShareModal.vue b/src/components/modals/ShareModal.vue
index 513ae0f..a9f1506 100644
--- a/src/components/modals/ShareModal.vue
+++ b/src/components/modals/ShareModal.vue
@@ -1,5 +1,6 @@
@@ -172,9 +282,10 @@ watch(
-
+
- Invite sent — watch the RSVPs roll in
+ {{ notice }}
@@ -241,7 +352,12 @@ watch(
Couldn't reach the server — try again in a moment.
- {{ inviteLink }}
+ {{ inviteLinkLabel }}