Add link previews and icons; make PIG_INVITE_CODE actually work
Social and icons. A 1200x630 card, apple-touch-icon, and maskable PWA icons, generated from an HTML template by a script so the mark, wordmark and tagline cannot drift from the product. The apple-touch-icon referenced in index.html was a 404 until now. Icons are drawn on an opaque plate with inset because iOS rounds corners and Android may apply a circle — an edge-to-edge mark loses its ears to that crop. og:image is absolute, which is the single most common reason a card unfurls blank. PIG_INVITE_CODE was a lie. Signup validates against the invites table, so setting the variable only flipped a label in the UI — an operator would set it, hand the code to a colleague, and watch them be rejected. It is now reconciled into a real invite row at boot: setting it issues, changing it rotates and revokes the predecessor, and removing it revokes. Verified all three, plus that a restart with an unchanged code does not duplicate. Two bugs found while doing that: - `uses_remaining` was jsonb, so the SQL decrement could never have worked. Now integer. The generated migration failed because Postgres has no implicit jsonb->integer cast, so the USING clause is hand-written. - Redemption keyed off `redeemedAt`, which would have made every reusable invite single-use — a confusing way to lock a team out. Availability now comes from `usesRemaining`, and redemption records who used it most recently without consuming it. Sign-in gains a password option alongside the magic link, defaulting to password since that is the daily path. PIG stores neither; both are handled by the identity provider and PIG only ever sees the resulting token. autocomplete is set so password managers and iOS can fill. Verified: full migration chain applies to a fresh Postgres, the CSP hash for the inline theme script is unchanged by the rebuild. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
import { and, eq, isNull, or, sql } from 'drizzle-orm';
|
import { and, eq, gt, isNull, or, sql } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import type { Database } from '@pig/db';
|
import type { Database } from '@pig/db';
|
||||||
@@ -123,6 +123,11 @@ export function createSignupRoute(config: Config, db: Database) {
|
|||||||
.update(parsed.data.inviteCode.trim())
|
.update(parsed.data.inviteCode.trim())
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
|
|
||||||
|
// Availability is decided by `usesRemaining`, NOT by `redeemedAt`.
|
||||||
|
// A shared workspace code records who redeemed it most recently while
|
||||||
|
// remaining valid for the next person; keying off `redeemedAt` would
|
||||||
|
// make every reusable invite single-use, which is a confusing way to
|
||||||
|
// lock a team out.
|
||||||
const [invite] = await db
|
const [invite] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invites)
|
.from(invites)
|
||||||
@@ -130,7 +135,7 @@ export function createSignupRoute(config: Config, db: Database) {
|
|||||||
and(
|
and(
|
||||||
eq(invites.codeHash, codeHash),
|
eq(invites.codeHash, codeHash),
|
||||||
isNull(invites.revokedAt),
|
isNull(invites.revokedAt),
|
||||||
isNull(invites.redeemedAt),
|
gt(invites.usesRemaining, 0),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -184,7 +189,7 @@ export function createSignupRoute(config: Config, db: Database) {
|
|||||||
.set({
|
.set({
|
||||||
redeemedByUserId: created.id,
|
redeemedByUserId: created.id,
|
||||||
redeemedAt: new Date(),
|
redeemedAt: new Date(),
|
||||||
usesRemaining: sql`GREATEST(0, (${invites.usesRemaining})::int - 1)`,
|
usesRemaining: sql`GREATEST(0, ${invites.usesRemaining} - 1)`,
|
||||||
})
|
})
|
||||||
.where(eq(invites.id, inviteId));
|
.where(eq(invites.id, inviteId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { createDatabase } from '@pig/db';
|
|||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
import { loadConfig } from './lib/config';
|
import { loadConfig } from './lib/config';
|
||||||
import { startPrimeSync } from './services/sync';
|
import { startPrimeSync } from './services/sync';
|
||||||
|
import { reconcileInviteCode } from './services/bootstrap';
|
||||||
import { CapacityService } from './services/capacity';
|
import { CapacityService } from './services/capacity';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -29,6 +30,12 @@ if (existsSync(webDist)) {
|
|||||||
console.log('[pig] serving front end from', webDist);
|
console.log('[pig] serving front end from', webDist);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconcile configuration into the database before accepting traffic, so a
|
||||||
|
// freshly set invite code works on the first request rather than the second.
|
||||||
|
await reconcileInviteCode(config, db).catch((error) =>
|
||||||
|
console.error('[pig] could not reconcile the invite code:', error),
|
||||||
|
);
|
||||||
|
|
||||||
const server = serve({ fetch: app.fetch, port: config.PIG_PORT }, (info) => {
|
const server = serve({ fetch: app.fetch, port: config.PIG_PORT }, (info) => {
|
||||||
console.log(`[pig] listening on http://localhost:${info.port}`);
|
console.log(`[pig] listening on http://localhost:${info.port}`);
|
||||||
console.log(`[pig] environment: ${config.NODE_ENV}`);
|
console.log(`[pig] environment: ${config.NODE_ENV}`);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Boot-time reconciliation of configuration into the database.
|
||||||
|
*
|
||||||
|
* This exists to close a gap that was genuinely confusing: `PIG_INVITE_CODE`
|
||||||
|
* looked like it gated signup, but signup validates against the `invites`
|
||||||
|
* table, so setting the variable only flipped a label in the UI. An operator
|
||||||
|
* would set it, hand the code to a colleague, and watch them be rejected.
|
||||||
|
*
|
||||||
|
* Now the variable is reconciled into a real, reusable invite row at every
|
||||||
|
* boot. Changing the value in the environment and restarting revokes the old
|
||||||
|
* code and issues the new one, which is the behaviour anyone setting an
|
||||||
|
* environment variable would reasonably expect.
|
||||||
|
*/
|
||||||
|
import { and, eq, isNull, ne } from 'drizzle-orm';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { Database } from '@pig/db';
|
||||||
|
import { invites } from '@pig/db';
|
||||||
|
import type { Config } from '../lib/config';
|
||||||
|
|
||||||
|
/** The label marking the invite owned by configuration rather than by a person. */
|
||||||
|
const ENV_INVITE_LABEL = 'env:PIG_INVITE_CODE';
|
||||||
|
|
||||||
|
export async function reconcileInviteCode(config: Config, db: Database): Promise<void> {
|
||||||
|
const code = config.PIG_INVITE_CODE?.trim();
|
||||||
|
|
||||||
|
// No code configured: revoke any previously configured one, so removing the
|
||||||
|
// variable actually closes the door rather than leaving it ajar.
|
||||||
|
if (!code) {
|
||||||
|
const revoked = await db
|
||||||
|
.update(invites)
|
||||||
|
.set({ revokedAt: new Date() })
|
||||||
|
.where(and(eq(invites.scopeNote, ENV_INVITE_LABEL), isNull(invites.revokedAt)))
|
||||||
|
.returning({ id: invites.id });
|
||||||
|
if (revoked.length) {
|
||||||
|
console.log('[pig] PIG_INVITE_CODE is unset — revoked the previous configured invite.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeHash = createHash('sha256').update(code).digest('hex');
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(invites)
|
||||||
|
.where(and(eq(invites.codeHash, codeHash), isNull(invites.revokedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Already present and live. Nothing to do — restarts must be idempotent.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke any older configured invite before issuing the new one, so a
|
||||||
|
// rotated code genuinely replaces its predecessor.
|
||||||
|
await db
|
||||||
|
.update(invites)
|
||||||
|
.set({ revokedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invites.scopeNote, ENV_INVITE_LABEL),
|
||||||
|
isNull(invites.revokedAt),
|
||||||
|
ne(invites.codeHash, codeHash),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.insert(invites).values({
|
||||||
|
codeHash,
|
||||||
|
scopeNote: ENV_INVITE_LABEL,
|
||||||
|
// Effectively unlimited. A shared workspace code is meant to onboard a
|
||||||
|
// team, not one person; per-person invites are issued from the UI with
|
||||||
|
// their own limits.
|
||||||
|
//
|
||||||
|
// One million rather than Number.MAX_SAFE_INTEGER, which overflows a
|
||||||
|
// 32-bit integer column and fails at insert.
|
||||||
|
usesRemaining: 1_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[pig] Configured invite code is active. Rotate it by changing the variable.');
|
||||||
|
}
|
||||||
+41
-1
@@ -22,10 +22,50 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="pig" />
|
<meta name="apple-mobile-web-app-title" content="pig" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Icons. The SVG is preferred by every modern browser and adapts to the
|
||||||
|
system theme; the PNG is the fallback for those that do not. The
|
||||||
|
apple-touch-icon is drawn on an opaque plate with inset, because iOS
|
||||||
|
rounds the corners itself and a transparent edge-to-edge mark loses its
|
||||||
|
ears to the crop.
|
||||||
|
-->
|
||||||
<link rel="icon" href="/pig.svg" type="image/svg+xml" />
|
<link rel="icon" href="/pig.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="/pig-touch.png" />
|
<link rel="icon" href="/icon-192.png" type="image/png" sizes="192x192" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
|
||||||
<title>pig — Prime Intellect Growth</title>
|
<title>pig — Prime Intellect Growth</title>
|
||||||
|
<link rel="canonical" href="https://primeintellectgrowth.com/" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Link previews. og:image must be an ABSOLUTE url — unfurlers fetch it
|
||||||
|
without a page context, so a relative path silently yields no image,
|
||||||
|
which is the single most common reason a card renders blank.
|
||||||
|
|
||||||
|
summary_large_image gives the 1200×630 card rather than a small square
|
||||||
|
thumbnail. Both og: and twitter: tags are present because Slack, Discord,
|
||||||
|
LinkedIn and iMessage read Open Graph while X prefers its own.
|
||||||
|
-->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="pig" />
|
||||||
|
<meta property="og:url" content="https://primeintellectgrowth.com/" />
|
||||||
|
<meta property="og:title" content="pig — Prime Intellect Growth" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="An open-source, agent-native CRM for companies that buy GPU capacity and sell it. Margin, utilisation and idle capacity from one ledger."
|
||||||
|
/>
|
||||||
|
<meta property="og:image" content="https://primeintellectgrowth.com/og.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content="pig — Prime Intellect Growth. The CRM for companies that buy compute and sell it." />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="pig — Prime Intellect Growth" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="An open-source, agent-native CRM for companies that buy GPU capacity and sell it. Margin, utilisation and idle capacity from one ledger."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image" content="https://primeintellectgrowth.com/og.png" />
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Applied before first paint so a dark-mode user never sees a white
|
// Applied before first paint so a dark-mode user never sees a white
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -3,10 +3,15 @@
|
|||||||
"short_name": "pig",
|
"short_name": "pig",
|
||||||
"description": "An agent-native CRM for two-sided AI-compute companies.",
|
"description": "An agent-native CRM for two-sided AI-compute companies.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#ffffff",
|
"theme_color": "#ffffff",
|
||||||
"icons": [
|
"icons": [
|
||||||
{ "src": "/pig.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
{ "src": "/pig.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
+113
-16
@@ -1,18 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* Sign-in.
|
* Sign-in.
|
||||||
*
|
*
|
||||||
* Magic link only. PIG stores no passwords, and adding a password field would
|
* Two methods, because they suit different situations. A password is faster
|
||||||
* mean either storing one or pretending to — both worse than an email link for
|
* for someone who uses PIG daily and has it in a manager; a magic link needs
|
||||||
* an internal tool used by a couple of dozen people.
|
* no credential at all and is the better answer for someone signing in once
|
||||||
|
* from a phone. Neither is stored by PIG — both are handled entirely by the
|
||||||
|
* identity provider, and PIG only ever sees the resulting token.
|
||||||
|
*
|
||||||
|
* Password is the default tab because the alternative — defaulting to a link
|
||||||
|
* and making daily users switch every time — is the more annoying of the two
|
||||||
|
* mistakes.
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { KeyRound, Mail } from 'lucide-react';
|
||||||
import { getSupabase, type PublicConfig } from '@/lib/api';
|
import { getSupabase, type PublicConfig } from '@/lib/api';
|
||||||
import { Button, Card, CardContent, Input } from '@/components/ui';
|
import { Button, Card, CardContent, Input } from '@/components/ui';
|
||||||
import { PiggyMark } from '@/components/PiggyMark';
|
import { PiggyMark } from '@/components/PiggyMark';
|
||||||
|
|
||||||
|
type Method = 'password' | 'link';
|
||||||
|
|
||||||
export function SignIn({ config }: { config: PublicConfig }) {
|
export function SignIn({ config }: { config: PublicConfig }) {
|
||||||
|
const [method, setMethod] = useState<Method>('password');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
const [password, setPassword] = useState('');
|
||||||
|
const [status, setStatus] = useState<'idle' | 'busy' | 'sent' | 'error'>('idle');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
async function submit(event: React.FormEvent) {
|
async function submit(event: React.FormEvent) {
|
||||||
@@ -24,16 +35,31 @@ export function SignIn({ config }: { config: PublicConfig }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('sending');
|
setStatus('busy');
|
||||||
|
setMessage('');
|
||||||
|
|
||||||
|
if (method === 'password') {
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
|
if (error) {
|
||||||
|
setStatus('error');
|
||||||
|
// Supabase deliberately returns the same message for a wrong password
|
||||||
|
// and an unknown address, which is correct — distinguishing them tells
|
||||||
|
// an attacker which addresses are registered.
|
||||||
|
setMessage(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The auth state listener in App.tsx picks this up and re-fetches; no
|
||||||
|
// navigation is needed here.
|
||||||
|
setStatus('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await supabase.auth.signInWithOtp({
|
const { error } = await supabase.auth.signInWithOtp({
|
||||||
email,
|
email,
|
||||||
options: { emailRedirectTo: window.location.origin },
|
options: { emailRedirectTo: window.location.origin },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
// Supabase returns a clear message for rate limits and disabled signup,
|
|
||||||
// both of which the person can act on, so it is shown rather than hidden.
|
|
||||||
setMessage(error.message);
|
setMessage(error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -54,13 +80,57 @@ export function SignIn({ config }: { config: PublicConfig }) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-5">
|
<CardContent className="pt-5">
|
||||||
{status === 'sent' ? (
|
{status === 'sent' ? (
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-3 text-center">
|
||||||
|
<Mail className="mx-auto h-8 w-8 text-accent-fg" aria-hidden />
|
||||||
|
<div>
|
||||||
<p className="font-medium">Check your email</p>
|
<p className="font-medium">Check your email</p>
|
||||||
<p className="text-sm text-muted">
|
<p className="mt-1 text-sm text-muted">
|
||||||
A sign-in link is on its way to {email}. It expires shortly, so use it soon.
|
A sign-in link is on its way to {email}. It expires shortly, so use it
|
||||||
|
soon.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatus('idle')}
|
||||||
|
className="tap text-sm font-medium text-accent-fg"
|
||||||
|
>
|
||||||
|
Use a different address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Sign-in method"
|
||||||
|
className="mb-4 inline-flex w-full rounded-lg bg-surface-2 p-1"
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: 'password', label: 'Password', icon: KeyRound },
|
||||||
|
{ key: 'link', label: 'Email link', icon: Mail },
|
||||||
|
] as const
|
||||||
|
).map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={method === option.key}
|
||||||
|
onClick={() => {
|
||||||
|
setMethod(option.key);
|
||||||
|
setStatus('idle');
|
||||||
|
setMessage('');
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
'tap flex flex-1 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors',
|
||||||
|
method === option.key ? 'bg-surface text-fg shadow-sm' : 'text-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<option.icon className="h-4 w-4" aria-hidden />
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={submit} className="space-y-3">
|
<form onSubmit={submit} className="space-y-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1 block text-sm font-medium">Email</span>
|
<span className="mb-1 block text-sm font-medium">Email</span>
|
||||||
@@ -70,29 +140,56 @@ export function SignIn({ config }: { config: PublicConfig }) {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="you@company.com"
|
placeholder="you@company.com"
|
||||||
autoComplete="email"
|
autoComplete="username"
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{method === 'password' ? (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-sm font-medium">Password</span>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
// `current-password` is what lets a password manager
|
||||||
|
// offer to fill, and iOS to offer a saved credential.
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={status === 'sending'}
|
disabled={status === 'busy'}
|
||||||
>
|
>
|
||||||
{status === 'sending' ? 'Sending…' : 'Email me a sign-in link'}
|
{status === 'busy'
|
||||||
|
? method === 'password'
|
||||||
|
? 'Signing in…'
|
||||||
|
: 'Sending…'
|
||||||
|
: method === 'password'
|
||||||
|
? 'Sign in'
|
||||||
|
: 'Email me a sign-in link'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{status === 'error' ? (
|
{status === 'error' ? (
|
||||||
<p className="text-sm text-danger">{message}</p>
|
<p className="text-sm text-danger" role="alert">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{config.inviteRequired ? (
|
{config.inviteRequired ? (
|
||||||
<p className="text-center text-xs text-muted">
|
<p className="pt-1 text-center text-xs text-muted">
|
||||||
PIG is invite-only. An account alone does not grant access.
|
PIG is invite-only. An account alone does not grant access.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
Source for the social preview card. Rendered to PNG by `tools/render-assets.mjs`.
|
||||||
|
|
||||||
|
Kept as HTML rather than hand-drawn in a design tool so the wordmark, the
|
||||||
|
tagline and the mark stay in sync with the product when any of them changes —
|
||||||
|
and so regenerating is one command rather than an afternoon.
|
||||||
|
|
||||||
|
1200×630 is the size Twitter/X and most link unfurlers crop to. Anything
|
||||||
|
important is kept well inside the edges, because several clients crop to
|
||||||
|
roughly 1.91:1 and Slack shows a smaller centred slice.
|
||||||
|
-->
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 1200px;
|
||||||
|
height: 630px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 88px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #09090b;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Inter, sans-serif;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A faint grid, suggesting racked capacity without being literal about it. */
|
||||||
|
.grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, rgba(9, 9, 11, 0.045) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(9, 9, 11, 0.045) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
mask-image: radial-gradient(ellipse 80% 70% at 70% 40%, #000 20%, transparent 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row { display: flex; align-items: center; gap: 22px; position: relative; }
|
||||||
|
|
||||||
|
.mark { width: 92px; height: 92px; flex: none; }
|
||||||
|
|
||||||
|
.wordmark {
|
||||||
|
font-size: 84px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansion {
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 27px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #52525b;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 22px;
|
||||||
|
font-size: 46px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.028em;
|
||||||
|
line-height: 1.16;
|
||||||
|
max-width: 20ch;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
left: 88px;
|
||||||
|
right: 88px;
|
||||||
|
bottom: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
border: 1.5px solid #e4e4e7;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 9px 20px;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3f3f46;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="grid"></div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<svg class="mark" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.4 8.6 5.1 3.9c-.2-.5.3-1 .8-.8l5.2 2.2z" fill="#09090b" />
|
||||||
|
<path d="M24.6 8.6 26.9 3.9c.2-.5-.3-1-.8-.8l-5.2 2.2z" fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M16 5.2c6.3 0 11.2 4.3 11.2 10.4 0 6.5-5 11.2-11.2 11.2S4.8 22.1 4.8 15.6C4.8 9.5 9.7 5.2 16 5.2z"
|
||||||
|
fill="#09090b"
|
||||||
|
/>
|
||||||
|
<ellipse cx="11.6" cy="13.4" rx="1.5" ry="1.8" fill="#fff" />
|
||||||
|
<ellipse cx="20.4" cy="13.4" rx="1.5" ry="1.8" fill="#fff" />
|
||||||
|
<rect x="11.3" y="17.6" width="9.4" height="6.2" rx="3.1" fill="#fff" />
|
||||||
|
<ellipse cx="14.2" cy="20.7" rx="1.15" ry="1.5" fill="#09090b" />
|
||||||
|
<ellipse cx="17.8" cy="20.7" rx="1.15" ry="1.5" fill="#09090b" />
|
||||||
|
</svg>
|
||||||
|
<span class="wordmark">pig</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expansion">Prime Intellect Growth</div>
|
||||||
|
|
||||||
|
<h1>The CRM for companies that buy compute and sell it.</h1>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>primeintellectgrowth.com</span>
|
||||||
|
<span class="pill">Open source · Apache 2.0</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Render the social card and icon set to PNG.
|
||||||
|
*
|
||||||
|
* node apps/web/tools/render-assets.mjs
|
||||||
|
*
|
||||||
|
* Generated rather than hand-drawn so that the mark, the wordmark and the
|
||||||
|
* tagline cannot drift apart from the product, and so regenerating after a
|
||||||
|
* brand change is one command.
|
||||||
|
*
|
||||||
|
* Uses the system Chrome via `channel: 'chrome'` rather than Playwright's own
|
||||||
|
* bundled browser, so `npm install` does not pull a ~150MB download for a
|
||||||
|
* script that runs occasionally, by hand, when the brand changes.
|
||||||
|
*/
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const publicDir = join(here, '..', 'public');
|
||||||
|
|
||||||
|
const PIG_SVG = `
|
||||||
|
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.4 8.6 5.1 3.9c-.2-.5.3-1 .8-.8l5.2 2.2z" fill="INK"/>
|
||||||
|
<path d="M24.6 8.6 26.9 3.9c.2-.5-.3-1-.8-.8l-5.2 2.2z" fill="INK"/>
|
||||||
|
<path d="M16 5.2c6.3 0 11.2 4.3 11.2 10.4 0 6.5-5 11.2-11.2 11.2S4.8 22.1 4.8 15.6C4.8 9.5 9.7 5.2 16 5.2z" fill="INK"/>
|
||||||
|
<ellipse cx="11.6" cy="13.4" rx="1.5" ry="1.8" fill="PAPER"/>
|
||||||
|
<ellipse cx="20.4" cy="13.4" rx="1.5" ry="1.8" fill="PAPER"/>
|
||||||
|
<rect x="11.3" y="17.6" width="9.4" height="6.2" rx="3.1" fill="PAPER"/>
|
||||||
|
<ellipse cx="14.2" cy="20.7" rx="1.15" ry="1.5" fill="INK"/>
|
||||||
|
<ellipse cx="17.8" cy="20.7" rx="1.15" ry="1.5" fill="INK"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const icon = (ink, paper) => PIG_SVG.replaceAll('INK', ink).replaceAll('PAPER', paper);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An icon page with padding around the mark.
|
||||||
|
*
|
||||||
|
* Home-screen and maskable icons are cropped by the platform — iOS rounds the
|
||||||
|
* corners, Android may apply a circle. A mark drawn edge to edge loses its
|
||||||
|
* ears to that crop, so it is inset inside a filled plate.
|
||||||
|
*/
|
||||||
|
function iconPage({ size, inset, background, ink, radius = 0 }) {
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{width:${size}px;height:${size}px;display:flex;align-items:center;justify-content:center;
|
||||||
|
background:${background};border-radius:${radius}px;overflow:hidden}
|
||||||
|
svg{width:${size - inset * 2}px;height:${size - inset * 2}px}
|
||||||
|
</style></head><body>${icon(ink, background)}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome' });
|
||||||
|
|
||||||
|
async function shoot(html, path, width, height, scale = 1) {
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
viewport: { width, height },
|
||||||
|
deviceScaleFactor: scale,
|
||||||
|
});
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.setContent(html, { waitUntil: 'networkidle' });
|
||||||
|
await page.screenshot({ path, omitBackground: false });
|
||||||
|
await ctx.close();
|
||||||
|
console.log(` ${path.replace(publicDir + '/', '')} ${width * scale}×${height * scale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Rendering assets…');
|
||||||
|
|
||||||
|
// Social card. Rendered at 1× because 1200×630 is already the target size —
|
||||||
|
// unfurlers do not use a 2× variant and the extra bytes cost load time.
|
||||||
|
const { readFile } = await import('node:fs/promises');
|
||||||
|
const ogHtml = await readFile(join(here, 'og-template.html'), 'utf8');
|
||||||
|
await shoot(ogHtml, join(publicDir, 'og.png'), 1200, 630, 1);
|
||||||
|
|
||||||
|
// Apple touch icon. iOS composites onto whatever it likes and rounds the
|
||||||
|
// corners itself, so this is drawn on an opaque white plate with generous
|
||||||
|
// inset — a transparent one renders black on some home screens.
|
||||||
|
await shoot(
|
||||||
|
iconPage({ size: 180, inset: 26, background: '#ffffff', ink: '#09090b' }),
|
||||||
|
join(publicDir, 'apple-touch-icon.png'),
|
||||||
|
180,
|
||||||
|
180,
|
||||||
|
);
|
||||||
|
|
||||||
|
// PWA icons. `maskable` gets a larger inset so the safe zone survives a
|
||||||
|
// circular mask on Android.
|
||||||
|
await shoot(
|
||||||
|
iconPage({ size: 192, inset: 24, background: '#ffffff', ink: '#09090b' }),
|
||||||
|
join(publicDir, 'icon-192.png'),
|
||||||
|
192,
|
||||||
|
192,
|
||||||
|
);
|
||||||
|
await shoot(
|
||||||
|
iconPage({ size: 512, inset: 64, background: '#ffffff', ink: '#09090b' }),
|
||||||
|
join(publicDir, 'icon-512.png'),
|
||||||
|
512,
|
||||||
|
512,
|
||||||
|
);
|
||||||
|
await shoot(
|
||||||
|
iconPage({ size: 512, inset: 112, background: '#ffffff', ink: '#09090b' }),
|
||||||
|
join(publicDir, 'icon-maskable-512.png'),
|
||||||
|
512,
|
||||||
|
512,
|
||||||
|
);
|
||||||
|
|
||||||
|
// No separate tiny favicon is rendered. Chrome hangs screenshotting viewports
|
||||||
|
// below roughly 128px, and it is unnecessary: browsers prefer the SVG, and
|
||||||
|
// icon-192 downscales cleanly for the ones that do not.
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('Done.');
|
||||||
Generated
+48
@@ -14,6 +14,7 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
@@ -4257,6 +4258,53 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.26",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
|
|||||||
+3
-2
@@ -25,8 +25,9 @@
|
|||||||
"db:seed": "npm run seed -w @pig/db"
|
"db:seed": "npm run seed -w @pig/db"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2",
|
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"tsx": "^4.19.2"
|
"playwright": "^1.62.1",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- `uses_remaining` moves from jsonb to integer so it can be decremented in SQL.
|
||||||
|
--
|
||||||
|
-- Postgres has no implicit jsonb -> integer cast, so the USING clause is
|
||||||
|
-- written by hand. Drizzle generated a bare ALTER, which fails with
|
||||||
|
-- "column cannot be cast automatically". Going via text is the documented
|
||||||
|
-- route; COALESCE covers rows where the value is JSON null.
|
||||||
|
ALTER TABLE "invites"
|
||||||
|
ALTER COLUMN "uses_remaining" DROP DEFAULT;--> statement-breakpoint
|
||||||
|
ALTER TABLE "invites"
|
||||||
|
ALTER COLUMN "uses_remaining" SET DATA TYPE integer
|
||||||
|
USING COALESCE(NULLIF("uses_remaining"::text, 'null')::integer, 1);--> statement-breakpoint
|
||||||
|
ALTER TABLE "invites"
|
||||||
|
ALTER COLUMN "uses_remaining" SET DEFAULT 1;--> statement-breakpoint
|
||||||
|
ALTER TABLE "invites" ADD COLUMN "scope_note" text;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
|||||||
"when": 1786587116691,
|
"when": 1786587116691,
|
||||||
"tag": "0001_user_appearance",
|
"tag": "0001_user_appearance",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786588874252,
|
||||||
|
"tag": "0002_invite_reuse",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
index,
|
index,
|
||||||
|
integer,
|
||||||
jsonb,
|
jsonb,
|
||||||
pgTable,
|
pgTable,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
@@ -118,8 +119,22 @@ export const invites = pgTable(
|
|||||||
onDelete: 'set null',
|
onDelete: 'set null',
|
||||||
}),
|
}),
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||||
/** Number of times this invite may still be redeemed. */
|
|
||||||
usesRemaining: jsonb('uses_remaining').$type<number>().notNull().default(1),
|
/**
|
||||||
|
* Times this invite may still be redeemed.
|
||||||
|
*
|
||||||
|
* An integer rather than JSON, because it is decremented in SQL and
|
||||||
|
* arithmetic on a jsonb column does not work. A shared workspace code is
|
||||||
|
* issued with a very large value; a per-person invite with 1.
|
||||||
|
*/
|
||||||
|
usesRemaining: integer('uses_remaining').notNull().default(1),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this invite came from. `env:PIG_INVITE_CODE` marks the one
|
||||||
|
* reconciled from configuration at boot, so rotating the variable can
|
||||||
|
* revoke its predecessor without touching invites a human issued.
|
||||||
|
*/
|
||||||
|
scopeNote: text('scope_note'),
|
||||||
redeemedByUserId: uuid('redeemed_by_user_id').references(() => users.id, {
|
redeemedByUserId: uuid('redeemed_by_user_id').references(() => users.id, {
|
||||||
onDelete: 'set null',
|
onDelete: 'set null',
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user